diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/CorrelationIdGenerator.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/CorrelationIdGenerator.cs new file mode 100644 index 00000000..4493cc95 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/CorrelationIdGenerator.cs @@ -0,0 +1,28 @@ +namespace Lavalink4NET.Experiments.Receive.Connections; + +// Based on https://source.dot.net/#Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets/src/Servers/Kestrel/shared/CorrelationIdGenerator.cs,f22660215e7e9131 + +internal static class CorrelationIdGenerator +{ + private static ReadOnlySpan Encode32Chars => "0123456789ABCDEFGHIJKLMNOPQRSTUV"; + private static long _lastId = DateTime.UtcNow.Ticks; + + public static string GetNextId() => GenerateId(Interlocked.Increment(ref _lastId)); + + private static string GenerateId(long id) => string.Create(13, id, (buffer, value) => + { + buffer[12] = Encode32Chars[(int)(value & 31)]; + buffer[11] = Encode32Chars[(int)((value >> 5) & 31)]; + buffer[10] = Encode32Chars[(int)((value >> 10) & 31)]; + buffer[9] = Encode32Chars[(int)((value >> 15) & 31)]; + buffer[8] = Encode32Chars[(int)((value >> 20) & 31)]; + buffer[7] = Encode32Chars[(int)((value >> 25) & 31)]; + buffer[6] = Encode32Chars[(int)((value >> 30) & 31)]; + buffer[5] = Encode32Chars[(int)((value >> 35) & 31)]; + buffer[4] = Encode32Chars[(int)((value >> 40) & 31)]; + buffer[3] = Encode32Chars[(int)((value >> 45) & 31)]; + buffer[2] = Encode32Chars[(int)((value >> 50) & 31)]; + buffer[1] = Encode32Chars[(int)((value >> 55) & 31)]; + buffer[0] = Encode32Chars[(int)((value >> 60) & 31)]; + }); +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Discovery/IIpDiscoveryService.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Discovery/IIpDiscoveryService.cs new file mode 100644 index 00000000..9c076d86 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Discovery/IIpDiscoveryService.cs @@ -0,0 +1,9 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Discovery; + +using System.Net; +using System.Net.Sockets; + +internal interface IIpDiscoveryService +{ + ValueTask DiscoverExternalAddressAsync(Socket socket, uint ssrc, CancellationToken cancellationToken = default); +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Discovery/IpDiscoveryService.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Discovery/IpDiscoveryService.cs new file mode 100644 index 00000000..b7d63de3 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Discovery/IpDiscoveryService.cs @@ -0,0 +1,107 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Discovery; + +using System.Buffers; +using System.Buffers.Binary; +using System.Net; +using System.Net.Sockets; +using System.Text; + +internal sealed class IpDiscoveryService : IIpDiscoveryService +{ + public async ValueTask DiscoverExternalAddressAsync(Socket socket, uint ssrc, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(socket); + + using var periodicTimer = new PeriodicTimer(TimeSpan.FromSeconds(1)); + + try + { + do + { + // discover external address + var address = await DiscoverExternalAddressSingleAsync( + socket: socket, + ssrc: ssrc, + cancellationToken: cancellationToken) + .ConfigureAwait(false); + + if (address is not null) + { + // got response! + return address; + } + } + while (await periodicTimer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)); + } + catch (OperationCanceledException) + { + } + + // no attempts left, give up or cancellation requested + return null; + } + + private async ValueTask DiscoverExternalAddressSingleAsync(Socket socket, uint ssrc, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + ArgumentNullException.ThrowIfNull(socket); + + // rent a buffer from the shared buffer array pool with a minimum size of 74 bytes (can + // hold the request). + var pooledBuffer = ArrayPool.Shared.Rent(74); + var buffer = pooledBuffer.AsMemory(0, 74); + + try + { + // encode payload data + BinaryPrimitives.WriteUInt16BigEndian(buffer.Span[0..], 0x01); // Request Payload Type + BinaryPrimitives.WriteUInt16BigEndian(buffer.Span[2..], 70); // encoded payload size (always 70) + BinaryPrimitives.WriteUInt32BigEndian(buffer.Span[4..], ssrc); // encode the client's SSRC (big-endian) + + // send payload + await socket + .SendAsync(buffer, SocketFlags.None, cancellationToken) + .ConfigureAwait(false); + + var startTime = DateTimeOffset.UtcNow; + + while (!cancellationToken.IsCancellationRequested) + { + var receiveResult = await socket + .ReceiveFromAsync(buffer, SocketFlags.None, new IPEndPoint(IPAddress.Any, 0), cancellationToken: cancellationToken) + .ConfigureAwait(false); + + if (receiveResult.ReceivedBytes is not 74) // Total Length + { + continue; + } + + var payloadType = BinaryPrimitives.ReadUInt16BigEndian(buffer.Span[0..]); + var encodedSize = BinaryPrimitives.ReadUInt16BigEndian(buffer.Span[2..]); + var ssrcValue = BinaryPrimitives.ReadUInt32BigEndian(buffer.Span[4..]); + + // validate header + if (payloadType is 0x02 && encodedSize is 70 && ssrcValue == ssrc) + { + var addressSpan = buffer[8..64]; + var addressTerminatorOffset = addressSpan.Span.IndexOf((byte)0); + var addressLength = addressTerminatorOffset is -1 ? 64 : addressTerminatorOffset; + var address = Encoding.ASCII.GetString(addressSpan.Span[..addressLength]); + var port = BinaryPrimitives.ReadUInt16BigEndian(buffer.Span[72..]); + + return new IPEndPoint(IPAddress.Parse(address), port); + } + } + } + finally + { + // return buffer to pool + ArrayPool.Shared.Return(pooledBuffer); + } + + // timeout exceeded + return null; + } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/ConnectionIdFeature.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/ConnectionIdFeature.cs new file mode 100644 index 00000000..e5f05517 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/ConnectionIdFeature.cs @@ -0,0 +1,8 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Features; + +using Microsoft.AspNetCore.Connections.Features; + +internal sealed record class ConnectionIdFeature : IConnectionIdFeature +{ + public required string ConnectionId { get; set; } +} \ No newline at end of file diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/ConnectionLabelFeature.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/ConnectionLabelFeature.cs new file mode 100644 index 00000000..31e0fa44 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/ConnectionLabelFeature.cs @@ -0,0 +1,3 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Features; + +internal sealed record class ConnectionLabelFeature(string Label) : IConnectionLabelFeature; \ No newline at end of file diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/GuildIdFeature.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/GuildIdFeature.cs new file mode 100644 index 00000000..1d9e54c5 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/GuildIdFeature.cs @@ -0,0 +1,3 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Features; + +internal sealed record class GuildIdFeature(ulong GuildId) : IGuildIdFeature; \ No newline at end of file diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/IConnectionLabelFeature.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/IConnectionLabelFeature.cs new file mode 100644 index 00000000..971eb057 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/IConnectionLabelFeature.cs @@ -0,0 +1,6 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Features; + +public interface IConnectionLabelFeature +{ + string Label { get; } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/IGuildIdFeature.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/IGuildIdFeature.cs new file mode 100644 index 00000000..455f6c79 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/IGuildIdFeature.cs @@ -0,0 +1,6 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Features; + +public interface IGuildIdFeature +{ + ulong GuildId { get; } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/IVoiceGatewayVersionFeature.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/IVoiceGatewayVersionFeature.cs new file mode 100644 index 00000000..7d7897ed --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/IVoiceGatewayVersionFeature.cs @@ -0,0 +1,6 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Features; + +public interface IVoiceGatewayVersionFeature +{ + int Version { get; } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/VoiceGatewayVersionFeature.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/VoiceGatewayVersionFeature.cs new file mode 100644 index 00000000..123dcda3 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/VoiceGatewayVersionFeature.cs @@ -0,0 +1,3 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Features; + +internal sealed record class VoiceGatewayVersionFeature(int Version) : IVoiceGatewayVersionFeature; \ No newline at end of file diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceConnectionHandle.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceConnectionHandle.cs new file mode 100644 index 00000000..228bc8a8 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceConnectionHandle.cs @@ -0,0 +1,15 @@ +namespace Lavalink4NET.Experiments.Receive.Connections; + +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Lavalink4NET.Experiments.Receive.Connections.Payloads; + +internal interface IVoiceConnectionHandle +{ + ValueTask SelectProtocolAsync(SelectProtocolPayload selectProtocolPayload, CancellationToken cancellationToken = default); + + ValueTask SetSessionDescriptionAsync(SessionDescriptionPayload sessionDescriptionPayload, CancellationToken cancellationToken = default); + + ValueTask SetReadyAsync(ReadyPayload readyPayload, CancellationToken cancellationToken = default); +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceConnectionHandler.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceConnectionHandler.cs new file mode 100644 index 00000000..475e95e5 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceConnectionHandler.cs @@ -0,0 +1,9 @@ +namespace Lavalink4NET.Experiments.Receive.Connections; + +internal interface IVoiceConnectionHandler +{ + ValueTask ProcessAsync( + VoiceConnectionContext connectionContext, + IVoiceConnectionHandle connectionHandle, + CancellationToken cancellationToken = default); +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceProtocolHandler.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceProtocolHandler.cs new file mode 100644 index 00000000..544023ae --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceProtocolHandler.cs @@ -0,0 +1,15 @@ +namespace Lavalink4NET.Experiments.Receive.Connections; + +using Lavalink4NET.Experiments.Receive.Connections.Payloads; + +internal interface IVoiceProtocolHandler +{ + ValueTask ReadAsync( + VoiceConnectionContext connectionContext, + CancellationToken cancellationToken = default); + + ValueTask WriteAsync( + VoiceConnectionContext connectionContext, + IVoicePayload payload, + CancellationToken cancellationToken = default); +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/PayloadReadCloseResult.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/PayloadReadCloseResult.cs new file mode 100644 index 00000000..97252300 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/PayloadReadCloseResult.cs @@ -0,0 +1,8 @@ +namespace Lavalink4NET.Experiments.Receive.Connections; + +using System.Net.WebSockets; + +internal sealed record class PayloadReadCloseResult( + WebSocketCloseStatus CloseStatus, + string? CloseStatusDescription, + bool ByRemote = false); \ No newline at end of file diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/PayloadReadResult.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/PayloadReadResult.cs new file mode 100644 index 00000000..9aa0df40 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/PayloadReadResult.cs @@ -0,0 +1,37 @@ +namespace Lavalink4NET.Experiments.Receive.Connections; + +using System.Diagnostics.CodeAnalysis; +using System.Net.WebSockets; +using Lavalink4NET.Experiments.Receive.Connections.Payloads; + +internal readonly record struct PayloadReadResult +{ + private readonly object? _value; + + public PayloadReadResult(IVoicePayload payload) + { + ArgumentNullException.ThrowIfNull(payload); + + _value = payload; + } + + public PayloadReadResult(WebSocketCloseStatus closeStatus, string? closeStatusDescription, bool byRemote = false) + { + _value = new PayloadReadCloseResult(closeStatus, closeStatusDescription, byRemote); + } + + public IVoicePayload? Payload => _value as IVoicePayload; + + public WebSocketCloseStatus CloseStatus => _value is PayloadReadCloseResult closeResult + ? closeResult.CloseStatus + : WebSocketCloseStatus.Empty; + + public string? CloseStatusDescription => _value is PayloadReadCloseResult closeResult + ? closeResult.CloseStatusDescription + : null; + + public bool ByRemote => _value is PayloadReadCloseResult closeResult && closeResult.ByRemote; + + [MemberNotNullWhen(true, nameof(Payload))] + public bool IsSuccess => _value is IVoicePayload; +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/HeartbeatIntervalJsonConverter.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/HeartbeatIntervalJsonConverter.cs new file mode 100644 index 00000000..bc63647a --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/HeartbeatIntervalJsonConverter.cs @@ -0,0 +1,17 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads.Converters; + +using System.Text.Json; +using System.Text.Json.Serialization; + +internal sealed class HeartbeatIntervalJsonConverter : JsonConverter +{ + public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return TimeSpan.FromMilliseconds(reader.GetDouble()); + } + + public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options) + { + writer.WriteNumberValue(value.TotalMilliseconds); + } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs new file mode 100644 index 00000000..fd32e5dd --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs @@ -0,0 +1,131 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads.Converters; + +using System; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +internal sealed class PayloadJsonConverter : JsonConverter +{ + public override IVoicePayload? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Expected the start of an object."); + } + + if (!reader.Read() || reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Expected a property name."); + } + + var propertyName = reader.GetString(); + + if (propertyName != "op") + { + throw new JsonException("Expected the 'op' property."); + } + + if (!reader.Read() || reader.TokenType != JsonTokenType.Number) + { + throw new JsonException("Expected a number."); + } + + var op = reader.GetInt32(); + + if (!reader.Read() || reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Expected a property name."); + } + + propertyName = reader.GetString(); + + if (propertyName != "d") + { + throw new JsonException("Expected the 'd' property."); + } + + if (!reader.Read()) + { + throw new JsonException("Expected a value."); + } + + var payload = op switch + { + 0 => JsonSerializer.Deserialize(ref reader, options) as IVoicePayload, + 1 => JsonSerializer.Deserialize(ref reader, options), + 2 => JsonSerializer.Deserialize(ref reader, options), + 3 => new HeartbeatPayload { SequenceNumber = reader.GetUInt64(), }, + 4 => JsonSerializer.Deserialize(ref reader, options), + 5 => JsonSerializer.Deserialize(ref reader, options), + 6 => new HeartbeatAckPayload { SequenceNumber = reader.GetUInt64(), }, + 7 => JsonSerializer.Deserialize(ref reader, options), + 8 => JsonSerializer.Deserialize(ref reader, options), + 9 => new ResumedPayload(), + _ => new DynamicVoicePayload { OperationCode = op, Data = (JsonObject)JsonNode.Parse(ref reader)!, }, + }; + + if (!reader.Read() || reader.TokenType != JsonTokenType.EndObject) + { + throw new JsonException("Expected the end of an object."); + } + + return payload; + } + + public override void Write(Utf8JsonWriter writer, IVoicePayload value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + if (value is DynamicVoicePayload dynamicPayload) + { + writer.WriteNumber("op", dynamicPayload.OperationCode); + writer.WritePropertyName("d"); + + if (dynamicPayload.Data is null) + { + writer.WriteNullValue(); + } + else + { + dynamicPayload.Data.WriteTo(writer); + } + + writer.WriteEndObject(); + return; + } + + var opCode = value switch + { + IdentifyPayload _ => 0, + SelectProtocolPayload _ => 1, + ReadyPayload _ => 2, + HeartbeatPayload _ => 3, + SessionDescriptionPayload _ => 4, + SpeakingPayload _ => 5, + HeartbeatAckPayload _ => 6, + ResumePayload _ => 7, + HelloPayload _ => 8, + ResumedPayload _ => 9, + _ => throw new JsonException("Unknown payload type.") + }; + + writer.WriteNumber("op", opCode); + writer.WritePropertyName("d"); + + if (value is HeartbeatPayload heartbeatPayload) + { + writer.WriteNumberValue(heartbeatPayload.SequenceNumber); + } + else if (value is HeartbeatAckPayload heartbeatAckPayload) + { + writer.WriteNumberValue(heartbeatAckPayload.SequenceNumber); + } + else + { + JsonSerializer.Serialize(writer, value, value.GetType(), options); + } + + writer.WriteEndObject(); + } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/SecretKeyJsonConverter.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/SecretKeyJsonConverter.cs new file mode 100644 index 00000000..cdedb8a4 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/SecretKeyJsonConverter.cs @@ -0,0 +1,28 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads.Converters; + +using System.Text.Json; +using System.Text.Json.Serialization; + +internal sealed class SecretKeyJsonConverter : JsonConverter> +{ + public override ReadOnlyMemory Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var byteData = JsonSerializer.Deserialize( + reader: ref reader, + jsonTypeInfo: PayloadJsonSerializerContext.Default.ImmutableArrayInt32); + + return byteData.Select(x => (byte)x).ToArray(); + } + + public override void Write(Utf8JsonWriter writer, ReadOnlyMemory value, JsonSerializerOptions options) + { + writer.WriteStartArray(); + + foreach (var item in value.Span) + { + writer.WriteNumberValue(item); + } + + writer.WriteEndArray(); + } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/SnowflakeJsonConverter.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/SnowflakeJsonConverter.cs new file mode 100644 index 00000000..84cf9bdf --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/SnowflakeJsonConverter.cs @@ -0,0 +1,19 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads.Converters; + +using System; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +internal sealed class SnowflakeJsonConverter : JsonConverter +{ + public override ulong Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return ulong.Parse(reader.GetString()!, CultureInfo.InvariantCulture); + } + + public override void Write(Utf8JsonWriter writer, ulong value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture)); + } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/DynamicVoicePayload.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/DynamicVoicePayload.cs new file mode 100644 index 00000000..5f084312 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/DynamicVoicePayload.cs @@ -0,0 +1,10 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; + +using System.Text.Json.Nodes; + +public sealed record class DynamicVoicePayload : IVoicePayload +{ + public int OperationCode { get; set; } + + public JsonObject? Data { get; set; } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/HeartbeatAckPayload.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/HeartbeatAckPayload.cs new file mode 100644 index 00000000..a8a26645 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/HeartbeatAckPayload.cs @@ -0,0 +1,6 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; + +public sealed record class HeartbeatAckPayload : IVoicePayload +{ + public ulong SequenceNumber { get; set; } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/HeartbeatPayload.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/HeartbeatPayload.cs new file mode 100644 index 00000000..d8890fac --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/HeartbeatPayload.cs @@ -0,0 +1,6 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; + +public sealed record class HeartbeatPayload : IVoicePayload +{ + public ulong SequenceNumber { get; set; } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/HelloPayload.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/HelloPayload.cs new file mode 100644 index 00000000..37fcdc03 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/HelloPayload.cs @@ -0,0 +1,12 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; + +using System.Text.Json.Serialization; +using Lavalink4NET.Experiments.Receive.Connections.Payloads.Converters; + +internal sealed record class HelloPayload : IVoicePayload +{ + [JsonRequired] + [JsonConverter(typeof(HeartbeatIntervalJsonConverter))] + [JsonPropertyName("heartbeat_interval")] + public required TimeSpan HeartbeatInterval { get; set; } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/IVoicePayload.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/IVoicePayload.cs new file mode 100644 index 00000000..5d8f74ac --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/IVoicePayload.cs @@ -0,0 +1,9 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; + +using System.Text.Json.Serialization; +using Lavalink4NET.Experiments.Receive.Connections.Payloads.Converters; + +[JsonConverter(typeof(PayloadJsonConverter))] +internal interface IVoicePayload +{ +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/IdentifyPayload.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/IdentifyPayload.cs new file mode 100644 index 00000000..e16c51b8 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/IdentifyPayload.cs @@ -0,0 +1,25 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; + +using System.Text.Json.Serialization; +using Lavalink4NET.Experiments.Receive.Connections.Payloads.Converters; + +internal sealed record class IdentifyPayload : IVoicePayload +{ + [JsonRequired] + [JsonPropertyName("server_id")] + [JsonConverter(typeof(SnowflakeJsonConverter))] + public required ulong GuildId { get; set; } + + [JsonRequired] + [JsonPropertyName("user_id")] + [JsonConverter(typeof(SnowflakeJsonConverter))] + public required ulong UserId { get; set; } + + [JsonRequired] + [JsonPropertyName("session_id")] + public required string SessionId { get; set; } + + [JsonRequired] + [JsonPropertyName("token")] + public required string Token { get; set; } +} \ No newline at end of file diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PayloadJsonSerializerContext.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PayloadJsonSerializerContext.cs new file mode 100644 index 00000000..ed2073f8 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PayloadJsonSerializerContext.cs @@ -0,0 +1,18 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; + +using System.Collections.Immutable; +using System.Text.Json.Serialization; + +[JsonSerializable(typeof(IVoicePayload))] +[JsonSerializable(typeof(IdentifyPayload))] +[JsonSerializable(typeof(ReadyPayload))] +[JsonSerializable(typeof(HelloPayload))] +[JsonSerializable(typeof(SelectProtocolPayload))] +[JsonSerializable(typeof(SessionDescriptionPayload))] +[JsonSerializable(typeof(ImmutableArray))] +[JsonSerializable(typeof(SpeakingPayload))] +[JsonSerializable(typeof(ResumePayload))] +[JsonSerializable(typeof(ResumedPayload))] +internal sealed partial class PayloadJsonSerializerContext : JsonSerializerContext +{ +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PooledBufferWriter.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PooledBufferWriter.cs new file mode 100644 index 00000000..586501ef --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PooledBufferWriter.cs @@ -0,0 +1,147 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; + +using System.Buffers; +using System.Diagnostics.CodeAnalysis; + +internal sealed class PooledBufferWriter : IBufferWriter, IDisposable +{ + private ArrayPool? _arrayPool; // null = disposed + private T[]? _buffer; + private int _bytesWritten; + + public PooledBufferWriter() + : this(ArrayPool.Shared) + { + } + + public PooledBufferWriter(ArrayPool arrayPool) + { + ArgumentNullException.ThrowIfNull(arrayPool); + + _arrayPool = arrayPool; + } + + public int Capacity => _buffer is null ? 0 : _buffer.Length; + + public int WrittenCount + { + get + { + EnsureNotDisposed(); + return _bytesWritten; + } + } + + public ReadOnlyMemory WrittenMemory + { + get + { + EnsureNotDisposed(); + return _buffer is null ? default : _buffer.AsMemory(0, _bytesWritten); + } + } + + public ArraySegment WrittenSegment + { + get + { + EnsureNotDisposed(); + + return _buffer is null + ? ArraySegment.Empty + : new ArraySegment(_buffer, 0, _bytesWritten); + } + } + + public ReadOnlySpan WrittenSpan => WrittenMemory.Span; + + /// + public void Advance(int count) + { + EnsureNotDisposed(); + + if (_buffer is null) + { + throw new InvalidOperationException("No buffer was allocated for this buffer writer."); + } + + // TODO: more checks + _bytesWritten += count; + } + + /// + public void Dispose() + { + if (_arrayPool is null) + { + return; + } + + var buffer = Interlocked.Exchange(ref _buffer, null); + + if (buffer is not null) + { + _arrayPool.Return(buffer); + } + + _arrayPool = null; + } + + /// + public Memory GetMemory(int sizeHint = 0) + { + EnsureNotDisposed(); + + if (sizeHint is 0) + { + sizeHint = 1; + } + + _buffer ??= _arrayPool.Rent(sizeHint); + + if (_buffer.Length - _bytesWritten < sizeHint) + { + var newBuffer = _arrayPool.Rent(sizeHint + _bytesWritten); + _buffer.AsSpan(0, _bytesWritten).CopyTo(newBuffer); + _arrayPool.Return(_buffer); + _buffer = newBuffer; + } + + return _buffer.AsMemory(_bytesWritten); + } + + public void EnsureCapacity(int capacity) + { + EnsureNotDisposed(); + + _buffer ??= _arrayPool.Rent(capacity); + + if (capacity > _buffer.Length) + { + var newBuffer = _arrayPool.Rent(capacity); + _buffer.AsSpan(0, _bytesWritten).CopyTo(newBuffer); + _arrayPool.Return(_buffer); + _buffer = newBuffer; + } + } + + /// + public Span GetSpan(int sizeHint = 0) => GetMemory(sizeHint).Span; + + public void Reset() + { + EnsureNotDisposed(); + + var buffer = Interlocked.Exchange(ref _buffer, null); + + if (buffer is not null) + { + _arrayPool.Return(buffer); + } + + _bytesWritten = 0; + } + + [MemberNotNull(nameof(_arrayPool))] + private void EnsureNotDisposed() => ObjectDisposedException.ThrowIf(_arrayPool is null, this); +} \ No newline at end of file diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/ReadyPayload.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/ReadyPayload.cs new file mode 100644 index 00000000..cff04a9a --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/ReadyPayload.cs @@ -0,0 +1,23 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; + +using System.Collections.Immutable; +using System.Text.Json.Serialization; + +internal sealed record class ReadyPayload : IVoicePayload +{ + [JsonRequired] + [JsonPropertyName("ssrc")] + public required uint Ssrc { get; set; } + + [JsonRequired] + [JsonPropertyName("ip")] + public required string Ip { get; set; } + + [JsonRequired] + [JsonPropertyName("port")] + public required int Port { get; set; } + + [JsonRequired] + [JsonPropertyName("modes")] + public required ImmutableArray Modes { get; set; } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/ResumePayload.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/ResumePayload.cs new file mode 100644 index 00000000..80b5dfd7 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/ResumePayload.cs @@ -0,0 +1,20 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; + +using System.Text.Json.Serialization; +using Lavalink4NET.Experiments.Receive.Connections.Payloads.Converters; + +internal sealed record class ResumePayload : IVoicePayload +{ + [JsonRequired] + [JsonConverter(typeof(SnowflakeJsonConverter))] + [JsonPropertyName("server_id")] + public required ulong GuildId { get; set; } + + [JsonRequired] + [JsonPropertyName("session_id")] + public required string SessionId { get; set; } + + [JsonRequired] + [JsonPropertyName("token")] + public required string Token { get; set; } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/ResumedPayload.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/ResumedPayload.cs new file mode 100644 index 00000000..49d262d5 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/ResumedPayload.cs @@ -0,0 +1,5 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; + +internal sealed record class ResumedPayload : IVoicePayload +{ +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SelectProtocolData.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SelectProtocolData.cs new file mode 100644 index 00000000..6b8a41a2 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SelectProtocolData.cs @@ -0,0 +1,18 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; + +using System.Text.Json.Serialization; + +internal sealed record class SelectProtocolData +{ + [JsonRequired] + [JsonPropertyName("address")] + public required string Address { get; set; } + + [JsonRequired] + [JsonPropertyName("port")] + public required int Port { get; set; } + + [JsonRequired] + [JsonPropertyName("mode")] + public required string Mode { get; set; } +} \ No newline at end of file diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SelectProtocolPayload.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SelectProtocolPayload.cs new file mode 100644 index 00000000..c18203f9 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SelectProtocolPayload.cs @@ -0,0 +1,14 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; + +using System.Text.Json.Serialization; + +internal sealed record class SelectProtocolPayload : IVoicePayload +{ + [JsonRequired] + [JsonPropertyName("protocol")] + public required string Protocol { get; set; } + + [JsonRequired] + [JsonPropertyName("data")] + public required SelectProtocolData Data { get; set; } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SessionDescriptionPayload.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SessionDescriptionPayload.cs new file mode 100644 index 00000000..1c6422e1 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SessionDescriptionPayload.cs @@ -0,0 +1,20 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; + +using System.Text.Json.Serialization; +using Lavalink4NET.Experiments.Receive.Connections.Payloads.Converters; + +internal sealed record class SessionDescriptionPayload : IVoicePayload +{ + [JsonRequired] + [JsonPropertyName("mode")] + public required string Mode { get; set; } + + [JsonRequired] + [JsonPropertyName("audio_codec")] + public required string AudioCodec { get; set; } + + [JsonRequired] + [JsonPropertyName("secret_key")] + [JsonConverter(typeof(SecretKeyJsonConverter))] + public required ReadOnlyMemory SecretKey { get; set; } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SpeakingFlags.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SpeakingFlags.cs new file mode 100644 index 00000000..77847a01 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SpeakingFlags.cs @@ -0,0 +1,10 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; + +[Flags] +internal enum SpeakingFlags : byte +{ + None = 0, + Microphone = 1 << 0, + Soundshare = 1 << 1, + Priority = 1 << 2 +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SpeakingPayload.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SpeakingPayload.cs new file mode 100644 index 00000000..8bce8a2d --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SpeakingPayload.cs @@ -0,0 +1,17 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; + +using System.Text.Json.Serialization; + +internal sealed record class SpeakingPayload : IVoicePayload +{ + [JsonRequired] + [JsonPropertyName("speaking")] + public required SpeakingFlags Flags { get; set; } + + [JsonPropertyName("delay")] + public int? Delay { get; set; } + + [JsonRequired] + [JsonPropertyName("ssrc")] + public required int Ssrc { get; set; } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionContext.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionContext.cs new file mode 100644 index 00000000..c9507e0d --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionContext.cs @@ -0,0 +1,19 @@ +namespace Lavalink4NET.Experiments.Receive.Connections; + +using System.Net.WebSockets; +using Microsoft.AspNetCore.Http.Features; + +public sealed class VoiceConnectionContext +{ + public VoiceConnectionContext(WebSocket webSocket) + { + ArgumentNullException.ThrowIfNull(webSocket); + + WebSocket = webSocket; + Features = new FeatureCollection(); + } + + public WebSocket WebSocket { get; } + + public IFeatureCollection Features { get; } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionHandle.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionHandle.cs new file mode 100644 index 00000000..72a9af9a --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionHandle.cs @@ -0,0 +1,171 @@ +namespace Lavalink4NET.Experiments.Receive.Connections; + +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Net; +using System.Net.Sockets; +using System.Text; +using Lavalink4NET.Experiments.Receive.Connections.Discovery; +using Lavalink4NET.Experiments.Receive.Connections.Payloads; + +internal sealed class VoiceConnectionHandle : IVoiceConnectionHandle +{ + private readonly IIpDiscoveryService _ipDiscoveryService; + private SelectProtocolPayload? _selectProtocolPayload; + private ReadyPayload? _readyPayload; + private SessionDescriptionPayload? _sessionDescriptionPayload; + private Socket? _localSocket; + private Socket? _remoteSocket; + + public VoiceConnectionHandle(IIpDiscoveryService ipDiscoveryService) + { + ArgumentNullException.ThrowIfNull(ipDiscoveryService); + + _ipDiscoveryService = ipDiscoveryService; + } + + public async ValueTask SelectProtocolAsync(SelectProtocolPayload selectProtocolPayload, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(selectProtocolPayload); + + _selectProtocolPayload = selectProtocolPayload; + + using var discoveryCancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + var externalRemoteAddress = await _ipDiscoveryService + .DiscoverExternalAddressAsync(_remoteSocket!, _readyPayload!.Ssrc, discoveryCancellationTokenSource.Token) + .ConfigureAwait(false); + + await CompleteAsync(cancellationToken).ConfigureAwait(false); + + return externalRemoteAddress; + } + + public async ValueTask SetReadyAsync(ReadyPayload readyPayload, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(readyPayload); + + if (_readyPayload is not null) + { + throw new InvalidOperationException("Ready payload already received."); + } + + _readyPayload = readyPayload; + + _localSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + _localSocket.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + + _remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + _remoteSocket.Bind(new IPEndPoint(IPAddress.Any, _readyPayload.Port)); + _remoteSocket.Connect(new IPEndPoint(IPAddress.Parse(_readyPayload.Ip), _readyPayload.Port)); + + _ = ProxyAsync(_localSocket!, _remoteSocket!, cancellationToken).AsTask(); + + await CompleteAsync(cancellationToken).ConfigureAwait(false); + + return (IPEndPoint)_localSocket.LocalEndPoint!; + } + + public async ValueTask SetSessionDescriptionAsync(SessionDescriptionPayload sessionDescriptionPayload, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(sessionDescriptionPayload); + + _sessionDescriptionPayload = sessionDescriptionPayload; + + await CompleteAsync(cancellationToken).ConfigureAwait(false); + } + + private ValueTask CompleteAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (_selectProtocolPayload is null || _readyPayload is null || _sessionDescriptionPayload is null) + { + return default; + } + + _ = ProxyAsync(_remoteSocket!, _localSocket!, cancellationToken).AsTask(); + + return default; + } + + private async ValueTask HandleIpDiscoveryAsync(Socket sourceSocket, IPEndPoint endPoint, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(sourceSocket); + + using var bufferWriter = new PooledBufferWriter(); + + var header = bufferWriter.GetMemory(8); + + BinaryPrimitives.WriteUInt16BigEndian(header.Span[0..2], 0x02); // Mark as response + BinaryPrimitives.WriteUInt16BigEndian(header.Span[2..4], 70); // Encode (constant) length + BinaryPrimitives.WriteUInt32BigEndian(header.Span[4..8], _readyPayload!.Ssrc); // Encode SSRC + + bufferWriter.Advance(8); + + // Encode IP + var ipContent = bufferWriter.GetMemory(64); + var encodedByteCount = Encoding.UTF8.GetBytes(endPoint.Address.ToString(), ipContent.Span); + ipContent.Span[encodedByteCount] = 0; + bufferWriter.Advance(64); + + // Encode port + var portContent = bufferWriter.GetMemory(2); + BinaryPrimitives.WriteUInt16BigEndian(portContent.Span, (ushort)endPoint.Port); + bufferWriter.Advance(2); + + if (!sourceSocket.Connected) + { + sourceSocket!.Connect(endPoint); + } + + await sourceSocket + .SendAsync(bufferWriter.WrittenMemory, SocketFlags.None, cancellationToken) + .ConfigureAwait(false); + } + + private async ValueTask ProxyAsync(Socket sourceSocket, Socket destinationSocket, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var buffer = ArrayPool.Shared.Rent(64 * 1024); + + try + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + var result = await sourceSocket + .ReceiveMessageFromAsync(buffer, SocketFlags.None, sourceSocket.LocalEndPoint!, cancellationToken) + .ConfigureAwait(false); + + if (result.ReceivedBytes is 0) + { + break; + } + + var data = new ReadOnlyMemory(buffer, 0, result.ReceivedBytes); + + if (sourceSocket == _localSocket && data.Length is 74 && data.Span[0..2].SequenceEqual(new byte[] { 0x00, 0x01, })) + { + await HandleIpDiscoveryAsync(sourceSocket, (IPEndPoint)result.RemoteEndPoint, cancellationToken).ConfigureAwait(false); + continue; + } + + await destinationSocket + .SendAsync(data, SocketFlags.None, cancellationToken) + .ConfigureAwait(false); + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionHandler.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionHandler.cs new file mode 100644 index 00000000..f40d24de --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionHandler.cs @@ -0,0 +1,227 @@ +namespace Lavalink4NET.Experiments.Receive.Connections; + +using System.Globalization; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; +using System.Web; +using Lavalink4NET.Clients; +using Lavalink4NET.Experiments.Receive.Connections.Features; +using Lavalink4NET.Experiments.Receive.Connections.Payloads; +using Lavalink4NET.Experiments.Receive.Sessions; +using Microsoft.AspNetCore.Connections.Features; + +internal sealed class VoiceConnectionHandler : IVoiceConnectionHandler +{ + private readonly IVoiceServerSessionManager _sessionManager; + private readonly IVoiceProtocolHandler _protocolHandler; + private readonly IHttpMessageHandlerFactory _httpMessageHandlerFactory; + private readonly ILogger _logger; + + public VoiceConnectionHandler( + IVoiceServerSessionManager sessionManager, + IVoiceProtocolHandler protocolHandler, + IHttpMessageHandlerFactory httpMessageHandlerFactory, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(sessionManager); + ArgumentNullException.ThrowIfNull(protocolHandler); + ArgumentNullException.ThrowIfNull(httpMessageHandlerFactory); + ArgumentNullException.ThrowIfNull(logger); + + _sessionManager = sessionManager; + _protocolHandler = protocolHandler; + _httpMessageHandlerFactory = httpMessageHandlerFactory; + _logger = logger; + } + + public async ValueTask ProcessAsync( + VoiceConnectionContext connectionContext, + IVoiceConnectionHandle connectionHandle, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(connectionContext); + + var payloadResult = await _protocolHandler + .ReadAsync(connectionContext, cancellationToken) + .ConfigureAwait(false); + + if (!payloadResult.IsSuccess) + { + throw new WebSocketException($"Failed to read initial payload: {payloadResult.CloseStatus} {payloadResult.CloseStatusDescription}"); + } + + if (payloadResult.Payload is not IdentifyPayload identifyPayload) + { + throw new WebSocketException("Expected identify payload."); + } + + if (!Guid.TryParseExact(identifyPayload.Token, "N", result: out var token) || + !_sessionManager.TryResolve(token, out var guildId, out var voiceServer)) + { + throw new WebSocketException("Invalid session id."); + } + + string sourceConnectionId; + if (connectionContext.Features.Get() is { } connectionIdFeature) + { + sourceConnectionId = connectionIdFeature.ConnectionId; + } + else + { + sourceConnectionId = CorrelationIdGenerator.GetNextId(); + connectionContext.Features.Set(new ConnectionIdFeature { ConnectionId = sourceConnectionId }); + } + + var remoteConnectionId = CorrelationIdGenerator.GetNextId(); + var sourceLabel = $"Local/{sourceConnectionId}@{guildId}"; + var remoteLabel = $"Remote/{sourceConnectionId}@{guildId}"; + + connectionContext.Features.Set(new GuildIdFeature(guildId)); + connectionContext.Features.Set(new ConnectionLabelFeature(sourceLabel)); + + using var gatewaySocket = new ClientWebSocket(); + using var httpMessageHandler = _httpMessageHandlerFactory.CreateHandler(); + using var httpMessageInvoker = new HttpMessageInvoker(httpMessageHandler); + var version = connectionContext.Features.Get()?.Version; + + var uri = BuildUri(voiceServer, version); + + await gatewaySocket + .ConnectAsync(uri, httpMessageInvoker, cancellationToken) + .ConfigureAwait(false); + + var remoteConnectionContext = new VoiceConnectionContext(gatewaySocket); + + remoteConnectionContext.Features.Set(new ConnectionIdFeature { ConnectionId = remoteConnectionId, }); + remoteConnectionContext.Features.Set(new ConnectionLabelFeature(remoteLabel)); + remoteConnectionContext.Features.Set(new VoiceGatewayVersionFeature(version ?? 4)); + remoteConnectionContext.Features.Set(new GuildIdFeature(guildId)); + + var remoteIdentifyPayload = new IdentifyPayload + { + GuildId = guildId, + Token = voiceServer.Token, + SessionId = identifyPayload.SessionId, + UserId = identifyPayload.UserId, + }; + + await _protocolHandler + .WriteAsync(remoteConnectionContext, remoteIdentifyPayload, cancellationToken) + .ConfigureAwait(false); + + var task1 = ProxyAsync(connectionContext, remoteConnectionContext, connectionHandle, isRemote: false, cancellationToken).AsTask(); + var task2 = ProxyAsync(remoteConnectionContext, connectionContext, connectionHandle, isRemote: true, cancellationToken).AsTask(); + + await Task + .WhenAny(task1, task2) + .ConfigureAwait(false); + } + + private async ValueTask ProxyAsync( + VoiceConnectionContext sourceConnectionContext, + VoiceConnectionContext destinationConnectionContext, + IVoiceConnectionHandle connectionHandle, + bool isRemote = false, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(sourceConnectionContext); + ArgumentNullException.ThrowIfNull(destinationConnectionContext); + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + var result = await _protocolHandler + .ReadAsync(sourceConnectionContext, cancellationToken) + .ConfigureAwait(false); + + if (!result.IsSuccess) + { + _logger.LogWarning("Failed to read payload: {CloseStatus} {CloseStatusDescription}", result.CloseStatus, result.CloseStatusDescription); + break; + } + + var receivedPayload = result.Payload; + + switch (receivedPayload) + { + case ReadyPayload payload when isRemote: + var localEndPoint = await connectionHandle + .SetReadyAsync(payload, cancellationToken) + .ConfigureAwait(false); + + receivedPayload = new ReadyPayload + { + Ssrc = payload.Ssrc, + Ip = localEndPoint.Address.ToString(), + Port = localEndPoint.Port, + Modes = payload.Modes, + }; + + break; + + case SessionDescriptionPayload payload when isRemote: + await connectionHandle + .SetSessionDescriptionAsync(payload, cancellationToken) + .ConfigureAwait(false); + + break; + + case SelectProtocolPayload payload when !isRemote: + var remoteEndPoint = await connectionHandle + .SelectProtocolAsync(payload, cancellationToken) + .ConfigureAwait(false); + + receivedPayload = new SelectProtocolPayload + { + Data = new SelectProtocolData + { + Address = remoteEndPoint.Address.ToString(), + Port = remoteEndPoint.Port, + Mode = payload.Data.Mode, + }, + Protocol = payload.Protocol, + }; + + break; + } + + await _protocolHandler + .WriteAsync(destinationConnectionContext, receivedPayload, cancellationToken) + .ConfigureAwait(false); + } + } + + private static Uri BuildUri(VoiceServer voiceServer, int? version = null) + { + var host = voiceServer.Endpoint; + var endPointSeparatorIndex = host.LastIndexOf(':'); + var port = default(int?); // WSS default port + + if (endPointSeparatorIndex is not -1) + { + host = host[..endPointSeparatorIndex]; + port = int.Parse(voiceServer.Endpoint[(endPointSeparatorIndex + 1)..]); + } + + var uriBuilder = new UriBuilder + { + Scheme = Uri.UriSchemeWss, + Host = host, + Port = port ?? 443, + Path = "/", + }; + + if (version.HasValue) + { + var queryParameters = HttpUtility.ParseQueryString(string.Empty); + queryParameters["v"] = version.Value.ToString(CultureInfo.InvariantCulture); + uriBuilder.Query = queryParameters.ToString(); + } + + return uriBuilder.Uri; + } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceProtocolHandler.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceProtocolHandler.cs new file mode 100644 index 00000000..0a561456 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceProtocolHandler.cs @@ -0,0 +1,125 @@ +namespace Lavalink4NET.Experiments.Receive.Connections; + +using System.Buffers; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Lavalink4NET.Experiments.Receive.Connections.Features; +using Lavalink4NET.Experiments.Receive.Connections.Payloads; +using Microsoft.AspNetCore.Connections.Features; +using Microsoft.AspNetCore.Http.Features; + +internal sealed class VoiceProtocolHandler : IVoiceProtocolHandler +{ + private readonly ILogger _logger; + + public VoiceProtocolHandler(ILogger logger) + { + ArgumentNullException.ThrowIfNull(logger); + + _logger = logger; + } + + public async ValueTask ReadAsync( + VoiceConnectionContext connectionContext, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(connectionContext); + + var label = connectionContext.Features.Get()?.Label + ?? connectionContext.Features.GetRequiredFeature().ConnectionId; + + var pooledBuffer = ArrayPool.Shared.Rent(64 * 1024); + + try + { + var buffer = new Memory(pooledBuffer); + + var result = await connectionContext.WebSocket + .ReceiveAsync(buffer, cancellationToken) + .ConfigureAwait(false); + + if (result.MessageType is WebSocketMessageType.Close) + { + var closeStatus = connectionContext.WebSocket.CloseStatus ?? WebSocketCloseStatus.NormalClosure; + + _logger.LogInformation( + "[{Label}] Lost connection to voice gateway: {CloseStatus} {CloseStatusDescription}.", + label, closeStatus, connectionContext.WebSocket.CloseStatusDescription); + + return new PayloadReadResult(closeStatus, connectionContext.WebSocket.CloseStatusDescription, byRemote: true); + } + + if (result.MessageType is not WebSocketMessageType.Text) + { + _logger.LogWarning("[{Label}] Received a non-text message over the voice gateway connection.", label); + + await connectionContext.WebSocket + .CloseAsync(WebSocketCloseStatus.InvalidMessageType, "Expected text message.", cancellationToken) + .ConfigureAwait(false); + + return new PayloadReadResult(WebSocketCloseStatus.InvalidMessageType, "Expected text message.", byRemote: false); + } + + if (!result.EndOfMessage) + { + _logger.LogWarning("[{Label}] Received a partial payload from voice gateway.", label); + + await connectionContext.WebSocket + .CloseAsync(WebSocketCloseStatus.MessageTooBig, "Payload is too large.", cancellationToken) + .ConfigureAwait(false); + + return new PayloadReadResult(WebSocketCloseStatus.MessageTooBig, "Payload is too large.", byRemote: false); + } + + var payloadBuffer = buffer[..result.Count]; + + if (_logger.IsEnabled(LogLevel.Trace)) + { + _logger.LogTrace("[{Label}] Received payload: {Payload}", label, Encoding.UTF8.GetString(payloadBuffer.Span)); + } + + var payload = JsonSerializer.Deserialize( + utf8Json: payloadBuffer.Span, + jsonTypeInfo: PayloadJsonSerializerContext.Default.IVoicePayload)!; + + return new PayloadReadResult(payload); + } + finally + { + ArrayPool.Shared.Return(pooledBuffer); + } + } + + public async ValueTask WriteAsync( + VoiceConnectionContext connectionContext, + IVoicePayload payload, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(connectionContext); + ArgumentNullException.ThrowIfNull(payload); + + var label = connectionContext.Features.Get()?.Label + ?? connectionContext.Features.GetRequiredFeature().ConnectionId; + + using var bufferWriter = new PooledBufferWriter(); + + using (var utf8JsonWriter = new Utf8JsonWriter(bufferWriter)) + { + JsonSerializer.Serialize(utf8JsonWriter, payload, PayloadJsonSerializerContext.Default.IVoicePayload); + } + + if (_logger.IsEnabled(LogLevel.Trace)) + { + _logger.LogTrace("[{Label}] Sending payload: {Payload}", label, Encoding.UTF8.GetString(bufferWriter.WrittenSpan)); + } + + await connectionContext.WebSocket + .SendAsync(bufferWriter.WrittenMemory, WebSocketMessageType.Text, true, cancellationToken) + .ConfigureAwait(false); + } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Extensions/ServiceCollectionExtensions.cs b/experiments/Lavalink4NET.Experiments.Receive/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..41d08ed7 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,31 @@ +namespace Lavalink4NET.Experiments.Receive.Extensions; + +using Lavalink4NET.Experiments.Receive.Connections; +using Lavalink4NET.Experiments.Receive.Connections.Discovery; +using Lavalink4NET.Experiments.Receive.Server; +using Lavalink4NET.Experiments.Receive.Sessions; +using Lavalink4NET.Players; +using Microsoft.Extensions.DependencyInjection.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddLavalinkReceive(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + + services.TryAddSingleton(); + services.TryAddSingleton(); + + services.Configure(static _ => { }); + + services.AddHostedService(); + + services.Replace(ServiceDescriptor.Singleton()); + + return services; + } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Lavalink4NET.Experiments.Receive.csproj b/experiments/Lavalink4NET.Experiments.Receive/Lavalink4NET.Experiments.Receive.csproj new file mode 100644 index 00000000..1010bcb9 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Lavalink4NET.Experiments.Receive.csproj @@ -0,0 +1,14 @@ + + + + Library + net8.0 + enable + enable + + + + + + + diff --git a/experiments/Lavalink4NET.Experiments.Receive/LavalinkReceiveVoiceServerInterceptor.cs b/experiments/Lavalink4NET.Experiments.Receive/LavalinkReceiveVoiceServerInterceptor.cs new file mode 100644 index 00000000..985ed1cf --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/LavalinkReceiveVoiceServerInterceptor.cs @@ -0,0 +1,47 @@ +namespace Lavalink4NET.Experiments.Receive; + +using System.Threading; +using System.Threading.Tasks; +using Lavalink4NET.Clients; +using Lavalink4NET.Experiments.Receive.Server; +using Lavalink4NET.Experiments.Receive.Sessions; +using Lavalink4NET.Players; +using Microsoft.Extensions.Options; + +internal sealed class LavalinkReceiveVoiceServerInterceptor : ILavalinkVoiceServerInterceptor +{ + private readonly IVoiceServerSessionManager _sessionManager; + private readonly LavalinkVoiceServerOptions _options; + private readonly ILogger _logger; + + public LavalinkReceiveVoiceServerInterceptor( + IVoiceServerSessionManager sessionManager, + IOptions options, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(sessionManager); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(logger); + + _sessionManager = sessionManager; + _options = options.Value; + _logger = logger; + } + + public ValueTask InterceptAsync( + ulong guildId, + VoiceServer voiceServer, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var sessionToken = _sessionManager.Allocate(guildId, voiceServer); + var proxiedVoiceServer = new VoiceServer(sessionToken.ToString("N"), $"localhost:{_options.Port}"); + + _logger.LogInformation( + "Mapping voice server '{OriginalEndpoint}' ({OriginalToken}) to '{ProxiedEndpoint}' ({ProxiedToken})", + voiceServer.Endpoint, voiceServer.Token, proxiedVoiceServer.Endpoint, proxiedVoiceServer.Token); + + return new ValueTask(proxiedVoiceServer); + } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Properties/launchSettings.json b/experiments/Lavalink4NET.Experiments.Receive/Properties/launchSettings.json new file mode 100644 index 00000000..58992fac --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Lavalink4NET.Experiments.Receive": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:56958;http://localhost:56959" + } + } +} \ No newline at end of file diff --git a/experiments/Lavalink4NET.Experiments.Receive/Server/ILavalinkVoiceServer.cs b/experiments/Lavalink4NET.Experiments.Receive/Server/ILavalinkVoiceServer.cs new file mode 100644 index 00000000..fc96bc37 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Server/ILavalinkVoiceServer.cs @@ -0,0 +1,11 @@ +namespace Lavalink4NET.Experiments.Receive.Server; + +using System.Threading; +using System.Threading.Tasks; + +public interface ILavalinkVoiceServer +{ + ValueTask StartAsync(CancellationToken cancellationToken = default); + + ValueTask StopAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkKestrelWebHostBuilder.cs b/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkKestrelWebHostBuilder.cs new file mode 100644 index 00000000..11a94502 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkKestrelWebHostBuilder.cs @@ -0,0 +1,41 @@ +namespace Lavalink4NET.Experiments.Receive.Server; + +using System; +using Microsoft.Extensions.DependencyInjection; + +internal sealed class LavalinkKestrelWebHostBuilder : IWebHostBuilder +{ + private readonly IServiceCollection _serviceCollection; + + public LavalinkKestrelWebHostBuilder(IServiceCollection serviceCollection) + { + ArgumentNullException.ThrowIfNull(serviceCollection); + + _serviceCollection = serviceCollection; + } + + IWebHostBuilder IWebHostBuilder.ConfigureServices(Action configureServices) + { + configureServices(_serviceCollection); + return this; + } + + + IWebHost IWebHostBuilder.Build() => throw new NotImplementedException(); + + IWebHostBuilder IWebHostBuilder.ConfigureAppConfiguration(Action configureDelegate) + { + configureDelegate(new WebHostBuilderContext(), new ConfigurationBuilder()); + return this; + } + + IWebHostBuilder IWebHostBuilder.ConfigureServices(Action configureServices) + { + configureServices(new WebHostBuilderContext(), _serviceCollection); + return this; + } + + string IWebHostBuilder.GetSetting(string key) => throw new NotImplementedException(); + + IWebHostBuilder IWebHostBuilder.UseSetting(string key, string? value) => throw new NotImplementedException(); +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkMeterFactory.cs b/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkMeterFactory.cs new file mode 100644 index 00000000..99b1ef47 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkMeterFactory.cs @@ -0,0 +1,18 @@ +namespace Lavalink4NET.Experiments.Receive.Server; + +using System; +using System.Diagnostics.Metrics; + +internal sealed class LavalinkMeterFactory : IMeterFactory +{ + public Meter Create(MeterOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + return new(options); + } + + public void Dispose() + { + } +} \ No newline at end of file diff --git a/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkVoiceServer.cs b/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkVoiceServer.cs new file mode 100644 index 00000000..ef3bc6ce --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkVoiceServer.cs @@ -0,0 +1,149 @@ +namespace Lavalink4NET.Experiments.Receive.Server; + +using System; +using System.Diagnostics.Metrics; +using System.Globalization; +using Lavalink4NET.Experiments.Receive.Connections; +using Lavalink4NET.Experiments.Receive.Connections.Discovery; +using Lavalink4NET.Experiments.Receive.Connections.Features; +using Microsoft.AspNetCore.Connections.Features; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.WebSockets; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; + +internal sealed class LavalinkVoiceServer : IHttpApplication, ILavalinkVoiceServer +{ + private readonly IServiceProvider _serviceProvider; + private readonly IVoiceConnectionHandler _voiceConnectionHandler; + private readonly IIpDiscoveryService _ipDiscoveryService; + private readonly IServer _server; + private readonly WebSocketMiddleware _webSocketMiddleware; + + public LavalinkVoiceServer( + IVoiceConnectionHandler voiceConnectionHandler, + IIpDiscoveryService ipDiscoveryService, + ILoggerFactory loggerFactory, + IOptions options) + { + ArgumentNullException.ThrowIfNull(voiceConnectionHandler); + + _voiceConnectionHandler = voiceConnectionHandler; + _ipDiscoveryService = ipDiscoveryService; + var services = new ServiceCollection(); + + // HTTP Kestrel Host + services.TryAddSingleton(); + services.TryAddSingleton(); + + // Logging + services.TryAddSingleton(loggerFactory); + services.TryAdd(ServiceDescriptor.Singleton(typeof(ILogger<>), typeof(Logger<>))); + + var builder = new LavalinkKestrelWebHostBuilder(services); + + builder.UseKestrel((context, serverOptions) => + { + serverOptions.ListenLocalhost(options.Value.Port, x => x.UseHttps()); + }); + + var webSocketOptions = new WebSocketOptions { }; + + _webSocketMiddleware = new WebSocketMiddleware( + next: ProcessRequestInternalAsync, + options: Options.Create(webSocketOptions), + loggerFactory: loggerFactory); + + _serviceProvider = services.BuildServiceProvider(); + _server = _serviceProvider.GetRequiredService(); + } + + public async ValueTask StartAsync(CancellationToken cancellationToken = default) + { + await _server + .StartAsync(this, cancellationToken) + .ConfigureAwait(false); + } + + public async ValueTask StopAsync(CancellationToken cancellationToken = default) + { + await _server + .StopAsync(cancellationToken) + .ConfigureAwait(false); + } + + HttpContext IHttpApplication.CreateContext(IFeatureCollection contextFeatures) + { + ArgumentNullException.ThrowIfNull(contextFeatures); + + return new DefaultHttpContext(contextFeatures); + } + + void IHttpApplication.DisposeContext(HttpContext context, Exception? exception) + { + } + + Task IHttpApplication.ProcessRequestAsync(HttpContext context) + { + ArgumentNullException.ThrowIfNull(context); + + return _webSocketMiddleware.Invoke(context); + } + + private async Task ProcessRequestInternalAsync(HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext); + + if (!httpContext.Request.Query.TryGetValue("v", out var versionValue)) + { + httpContext.Response.StatusCode = StatusCodes.Status200OK; + + await httpContext.Response + .WriteAsync("Lavalink4NET Voice Proxy Server") + .ConfigureAwait(false); + + return; + } + + var cancellationToken = httpContext.RequestAborted; + cancellationToken.ThrowIfCancellationRequested(); + + if (!httpContext.WebSockets.IsWebSocketRequest) + { + httpContext.Response.StatusCode = StatusCodes.Status426UpgradeRequired; + httpContext.Response.Headers[HeaderNames.Upgrade] = "websocket"; + httpContext.Response.Headers[HeaderNames.Connection] = "Upgrade"; + + return; + } + + if (!int.TryParse(versionValue.ToString(), CultureInfo.InvariantCulture, out var version) || version is not 3 and not 4) + { + httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + + await httpContext.Response + .WriteAsync("Invalid version parameter.") + .ConfigureAwait(false); + + return; + } + + var webSocketAcceptContext = new WebSocketAcceptContext { }; + + var webSocket = await httpContext.WebSockets + .AcceptWebSocketAsync(webSocketAcceptContext) + .ConfigureAwait(false); + + var connectionContext = new VoiceConnectionContext(webSocket); + + connectionContext.Features.Set(httpContext.Features.GetRequiredFeature()); + connectionContext.Features.Set(new VoiceGatewayVersionFeature(version)); + + await _voiceConnectionHandler + .ProcessAsync(connectionContext, new VoiceConnectionHandle(_ipDiscoveryService), cancellationToken) + .ConfigureAwait(false); + } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkVoiceServerHost.cs b/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkVoiceServerHost.cs new file mode 100644 index 00000000..e58ebecd --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkVoiceServerHost.cs @@ -0,0 +1,30 @@ +namespace Lavalink4NET.Experiments.Receive.Server; + +using System.Threading; +using System.Threading.Tasks; + +internal sealed class LavalinkVoiceServerHost : IHostedService +{ + private readonly ILavalinkVoiceServer _lavalinkVoiceServer; + + public LavalinkVoiceServerHost(ILavalinkVoiceServer lavalinkVoiceServer) + { + ArgumentNullException.ThrowIfNull(lavalinkVoiceServer); + + _lavalinkVoiceServer = lavalinkVoiceServer; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + return _lavalinkVoiceServer.StartAsync(cancellationToken).AsTask(); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + return _lavalinkVoiceServer.StopAsync(cancellationToken).AsTask(); + } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkVoiceServerOptions.cs b/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkVoiceServerOptions.cs new file mode 100644 index 00000000..cc90a84c --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkVoiceServerOptions.cs @@ -0,0 +1,6 @@ +namespace Lavalink4NET.Experiments.Receive.Server; + +public sealed record class LavalinkVoiceServerOptions +{ + public int Port { get; set; } = 16389; +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkWebHostEnvironment.cs b/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkWebHostEnvironment.cs new file mode 100644 index 00000000..7514adb4 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkWebHostEnvironment.cs @@ -0,0 +1,31 @@ +namespace Lavalink4NET.Experiments.Receive.Server; + +using System; +using Microsoft.Extensions.FileProviders; + +internal sealed class LavalinkWebHostEnvironment : IHostEnvironment +{ + public string EnvironmentName + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } + + public string ApplicationName + { + get => "Lavalink"; + set => throw new NotImplementedException(); + } + + public string ContentRootPath + { + get => Directory.GetCurrentDirectory(); + set => throw new NotImplementedException(); + } + + public IFileProvider ContentRootFileProvider + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Sessions/IVoiceServerSessionManager.cs b/experiments/Lavalink4NET.Experiments.Receive/Sessions/IVoiceServerSessionManager.cs new file mode 100644 index 00000000..23def27a --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Sessions/IVoiceServerSessionManager.cs @@ -0,0 +1,10 @@ +namespace Lavalink4NET.Experiments.Receive.Sessions; + +using Lavalink4NET.Clients; + +interface IVoiceServerSessionManager +{ + Guid Allocate(ulong guildId, VoiceServer voiceServer); + + bool TryResolve(Guid sessionId, out ulong guildId, out VoiceServer voiceServer); +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Sessions/VoiceServerSessionManager.cs b/experiments/Lavalink4NET.Experiments.Receive/Sessions/VoiceServerSessionManager.cs new file mode 100644 index 00000000..169c259c --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Sessions/VoiceServerSessionManager.cs @@ -0,0 +1,38 @@ +namespace Lavalink4NET.Experiments.Receive.Sessions; + +using System.Collections.Concurrent; +using Lavalink4NET.Clients; + +internal sealed class VoiceServerSessionManager : IVoiceServerSessionManager +{ + private readonly ConcurrentDictionary _voiceServers; + + public VoiceServerSessionManager() + { + _voiceServers = new ConcurrentDictionary(); + } + + public Guid Allocate(ulong guildId, VoiceServer voiceServer) + { + var sessionId = Guid.NewGuid(); + _voiceServers.TryAdd(sessionId, (guildId, voiceServer)); + + return sessionId; + } + + public bool TryResolve(Guid sessionId, out ulong guildId, out VoiceServer voiceServer) + { + if (_voiceServers.TryRemove(sessionId, out var pair)) + { + guildId = pair.GuildId; + voiceServer = pair.VoiceServer; + + return true; + } + + guildId = default; + voiceServer = default; + + return false; + } +} \ No newline at end of file diff --git a/src/Lavalink4NET.Rest/LavalinkApiClient.cs b/src/Lavalink4NET.Rest/LavalinkApiClient.cs index 6cb5d5fc..5317cc53 100644 --- a/src/Lavalink4NET.Rest/LavalinkApiClient.cs +++ b/src/Lavalink4NET.Rest/LavalinkApiClient.cs @@ -423,19 +423,6 @@ internal static async ValueTask EnsureSuccessStatusCodeAsync(HttpResponseMessage } } -internal sealed class RemoteLavalinkException : Exception -{ - private readonly string? _stackTrace; - - public RemoteLavalinkException(string? message, string? stackTrace) - : base(message) - { - _stackTrace = stackTrace; - } - - public override string? StackTrace => _stackTrace ?? base.StackTrace; -} - internal static class StrictSearchHelper { public static string Process(StrictSearchBehavior searchBehavior, string identifier, TrackSearchMode searchMode) => searchBehavior switch diff --git a/src/Lavalink4NET.Rest/RemoteLavalinkException.cs b/src/Lavalink4NET.Rest/RemoteLavalinkException.cs new file mode 100644 index 00000000..260280a0 --- /dev/null +++ b/src/Lavalink4NET.Rest/RemoteLavalinkException.cs @@ -0,0 +1,14 @@ +namespace Lavalink4NET.Rest; + +public sealed class RemoteLavalinkException : Exception +{ + private readonly string? _stackTrace; + + public RemoteLavalinkException(string? message, string? stackTrace) + : base(message) + { + _stackTrace = stackTrace; + } + + public override string? StackTrace => _stackTrace ?? base.StackTrace; +} diff --git a/src/Lavalink4NET.sln b/src/Lavalink4NET.sln index 4aab41d1..7853d93a 100644 --- a/src/Lavalink4NET.sln +++ b/src/Lavalink4NET.sln @@ -88,13 +88,15 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lavalink4NET.Integrations.L EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lavalink4NET.Integrations.Lavasrc.Tests", "..\tests\Lavalink4NET.Integrations.Lavasrc.Tests\Lavalink4NET.Integrations.Lavasrc.Tests.csproj", "{5779F765-5F0D-422C-984A-7E44EAE737C8}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lavalink4NET.NetCord", "Lavalink4NET.NetCord\Lavalink4NET.NetCord.csproj", "{8587F98B-CFE1-4559-9614-ED3B2B0C4F4E}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lavalink4NET.NetCord", "Lavalink4NET.NetCord\Lavalink4NET.NetCord.csproj", "{8587F98B-CFE1-4559-9614-ED3B2B0C4F4E}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lavalink4NET.Samples.NetCord", "..\samples\Lavalink4NET.Samples.NetCord\Lavalink4NET.Samples.NetCord.csproj", "{02FE863F-D979-439A-9A51-C4EA69D58D29}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lavalink4NET.Samples.NetCord", "..\samples\Lavalink4NET.Samples.NetCord\Lavalink4NET.Samples.NetCord.csproj", "{02FE863F-D979-439A-9A51-C4EA69D58D29}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lavalink4NET.Integrations.LyricsJava", "Lavalink4NET.Integrations.LyricsJava\Lavalink4NET.Integrations.LyricsJava.csproj", "{9A30E985-6D67-41D4-A12F-F1ADCD2ED0FE}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lavalink4NET.Integrations.LyricsJava", "Lavalink4NET.Integrations.LyricsJava\Lavalink4NET.Integrations.LyricsJava.csproj", "{9A30E985-6D67-41D4-A12F-F1ADCD2ED0FE}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lavalink4NET.Integrations.LyricsJava.Tests", "Lavalink4NET.Integrations.LyricsJava.Tests\Lavalink4NET.Integrations.LyricsJava.Tests.csproj", "{176B0345-DF57-42B4-A8FD-4E6436D9554C}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lavalink4NET.Integrations.LyricsJava.Tests", "Lavalink4NET.Integrations.LyricsJava.Tests\Lavalink4NET.Integrations.LyricsJava.Tests.csproj", "{176B0345-DF57-42B4-A8FD-4E6436D9554C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lavalink4NET.Experiments.Receive", "..\experiments\Lavalink4NET.Experiments.Receive\Lavalink4NET.Experiments.Receive.csproj", "{F7EC54EF-8C23-4586-BC86-D9B7B702CE14}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -258,6 +260,10 @@ Global {176B0345-DF57-42B4-A8FD-4E6436D9554C}.Debug|Any CPU.Build.0 = Debug|Any CPU {176B0345-DF57-42B4-A8FD-4E6436D9554C}.Release|Any CPU.ActiveCfg = Release|Any CPU {176B0345-DF57-42B4-A8FD-4E6436D9554C}.Release|Any CPU.Build.0 = Release|Any CPU + {F7EC54EF-8C23-4586-BC86-D9B7B702CE14}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F7EC54EF-8C23-4586-BC86-D9B7B702CE14}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F7EC54EF-8C23-4586-BC86-D9B7B702CE14}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F7EC54EF-8C23-4586-BC86-D9B7B702CE14}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/Lavalink4NET/Extensions/ServiceCollectionExtensions.cs b/src/Lavalink4NET/Extensions/ServiceCollectionExtensions.cs index 9aaf9342..337865b3 100644 --- a/src/Lavalink4NET/Extensions/ServiceCollectionExtensions.cs +++ b/src/Lavalink4NET/Extensions/ServiceCollectionExtensions.cs @@ -31,6 +31,7 @@ public static IServiceCollection AddLavalinkCore(this IServiceCollection service services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); services.AddHostedService(); diff --git a/src/Lavalink4NET/Players/ILavalinkVoiceServerInterceptor.cs b/src/Lavalink4NET/Players/ILavalinkVoiceServerInterceptor.cs new file mode 100644 index 00000000..ca89d8a1 --- /dev/null +++ b/src/Lavalink4NET/Players/ILavalinkVoiceServerInterceptor.cs @@ -0,0 +1,13 @@ +namespace Lavalink4NET.Players; + +using System.Threading; +using System.Threading.Tasks; +using Lavalink4NET.Clients; + +public interface ILavalinkVoiceServerInterceptor +{ + ValueTask InterceptAsync( + ulong guildId, + VoiceServer voiceServer, + CancellationToken cancellationToken = default); +} diff --git a/src/Lavalink4NET/Players/IPlayerProperties.cs b/src/Lavalink4NET/Players/IPlayerProperties.cs index 33d88b78..fae7ef7c 100644 --- a/src/Lavalink4NET/Players/IPlayerProperties.cs +++ b/src/Lavalink4NET/Players/IPlayerProperties.cs @@ -30,6 +30,8 @@ public interface IPlayerProperties IServiceProvider? ServiceProvider { get; } + ILavalinkVoiceServerInterceptor VoiceServerInterceptor { get; } + ulong VoiceChannelId { get; } string SessionId { get; } diff --git a/src/Lavalink4NET/Players/LavalinkPlayer.cs b/src/Lavalink4NET/Players/LavalinkPlayer.cs index ea4c63d5..5ba54eb3 100644 --- a/src/Lavalink4NET/Players/LavalinkPlayer.cs +++ b/src/Lavalink4NET/Players/LavalinkPlayer.cs @@ -646,17 +646,17 @@ protected virtual async ValueTask NotifyVoiceStateUpdatedAsync(VoiceState voiceS } } - protected virtual ValueTask NotifyVoiceServerUpdatedAsync(VoiceServer voiceServer, CancellationToken cancellationToken = default) + protected virtual async ValueTask NotifyVoiceServerUpdatedAsync(VoiceServer voiceServer, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); if (_disposed is 1) { - return ValueTask.CompletedTask; + return; } VoiceServer = voiceServer; - return UpdateVoiceCredentialsAsync(cancellationToken); + await UpdateVoiceCredentialsAsync(cancellationToken).ConfigureAwait(false); } ValueTask ILavalinkPlayerListener.NotifyVoiceStateUpdatedAsync(VoiceState voiceState, CancellationToken cancellationToken) diff --git a/src/Lavalink4NET/Players/LavalinkPlayerHandle.cs b/src/Lavalink4NET/Players/LavalinkPlayerHandle.cs index 423f00e6..40515e62 100644 --- a/src/Lavalink4NET/Players/LavalinkPlayerHandle.cs +++ b/src/Lavalink4NET/Players/LavalinkPlayerHandle.cs @@ -96,7 +96,9 @@ public async ValueTask UpdateVoiceServerAsync(VoiceServer voiceServer, Cancellat return; } - _voiceServer = voiceServer; + _voiceServer = await _playerContext.VoiceServerInterceptor + .InterceptAsync(_guildId, voiceServer, cancellationToken) + .ConfigureAwait(false); if (_voiceState is not null) { diff --git a/src/Lavalink4NET/Players/LavalinkVoiceServerInterceptor.cs b/src/Lavalink4NET/Players/LavalinkVoiceServerInterceptor.cs new file mode 100644 index 00000000..c83f96fb --- /dev/null +++ b/src/Lavalink4NET/Players/LavalinkVoiceServerInterceptor.cs @@ -0,0 +1,16 @@ +namespace Lavalink4NET.Players; + +using System.Threading; +using System.Threading.Tasks; +using Lavalink4NET.Clients; + +internal sealed class LavalinkVoiceServerInterceptor : ILavalinkVoiceServerInterceptor +{ + public ValueTask InterceptAsync( + ulong guildId, + VoiceServer voiceServer, + CancellationToken cancellationToken = default) + { + return new ValueTask(voiceServer); + } +} \ No newline at end of file diff --git a/src/Lavalink4NET/Players/PlayerContext.cs b/src/Lavalink4NET/Players/PlayerContext.cs index f3d35708..22a7d728 100644 --- a/src/Lavalink4NET/Players/PlayerContext.cs +++ b/src/Lavalink4NET/Players/PlayerContext.cs @@ -9,4 +9,5 @@ internal sealed record class PlayerContext( ILavalinkSessionProvider SessionProvider, IDiscordClientWrapper DiscordClient, ISystemClock SystemClock, - IPlayerLifecycleNotifier? LifecycleNotifier); + IPlayerLifecycleNotifier? LifecycleNotifier, + ILavalinkVoiceServerInterceptor VoiceServerInterceptor); diff --git a/src/Lavalink4NET/Players/PlayerManager.cs b/src/Lavalink4NET/Players/PlayerManager.cs index 376c5bd3..9fa376e7 100644 --- a/src/Lavalink4NET/Players/PlayerManager.cs +++ b/src/Lavalink4NET/Players/PlayerManager.cs @@ -30,11 +30,13 @@ public PlayerManager( IServiceProvider? serviceProvider, IDiscordClientWrapper discordClient, ILavalinkSessionProvider sessionProvider, + ILavalinkVoiceServerInterceptor voiceServerInterceptor, ISystemClock systemClock, ILoggerFactory loggerFactory) { ArgumentNullException.ThrowIfNull(discordClient); ArgumentNullException.ThrowIfNull(sessionProvider); + ArgumentNullException.ThrowIfNull(voiceServerInterceptor); ArgumentNullException.ThrowIfNull(systemClock); ArgumentNullException.ThrowIfNull(loggerFactory); @@ -49,7 +51,8 @@ public PlayerManager( SessionProvider: sessionProvider, DiscordClient: discordClient, SystemClock: systemClock, - LifecycleNotifier: this); + LifecycleNotifier: this, + VoiceServerInterceptor: voiceServerInterceptor); DiscordClient.VoiceStateUpdated += OnVoiceStateUpdated; DiscordClient.VoiceServerUpdated += OnVoiceServerUpdated; @@ -133,6 +136,10 @@ LavalinkPlayerHandle Create(ulong guildId) logger: _loggerFactory.CreateLogger()); } + await _playerContext.DiscordClient + .WaitForReadyAsync(cancellationToken) + .ConfigureAwait(false); + var handle = _handles.GetOrAdd(guildId, Create); if (handle.Player?.VoiceChannelId != voiceChannelId) diff --git a/src/Lavalink4NET/Players/PlayerProperties.cs b/src/Lavalink4NET/Players/PlayerProperties.cs index 084d330d..eb5c0bd4 100644 --- a/src/Lavalink4NET/Players/PlayerProperties.cs +++ b/src/Lavalink4NET/Players/PlayerProperties.cs @@ -28,4 +28,6 @@ internal sealed record class PlayerProperties( public IServiceProvider? ServiceProvider => Context.ServiceProvider; public ISystemClock SystemClock => Context.SystemClock; + + public ILavalinkVoiceServerInterceptor VoiceServerInterceptor => Context.VoiceServerInterceptor; }