elliebot/src/EllieBot.Voice/VoiceGateway.cs
2024-05-12 21:22:46 +12:00

375 lines
No EOL
13 KiB
C#

using EllieBot.Voice.Models;
using Discord.Models.Gateway;
using Newtonsoft.Json.Linq;
using Serilog;
using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Ayu.Discord.Gateway;
using Newtonsoft.Json;
namespace EllieBot.Voice
{
public class VoiceGateway
{
private class QueueItem
{
public VoicePayload Payload { get; }
public TaskCompletionSource<bool> Result { get; }
public QueueItem(VoicePayload payload, TaskCompletionSource<bool> result)
{
Payload = payload;
Result = result;
}
}
private readonly ulong _guildId;
private readonly ulong _userId;
private readonly string _sessionId;
private readonly string _token;
private readonly string _endpoint;
private readonly Uri _websocketUrl;
private readonly Channel<QueueItem> _channel;
public TaskCompletionSource<bool> ConnectingFinished { get; }
private readonly Random _rng;
private readonly SocketClient _ws;
private readonly UdpClient _udpClient;
private Timer? _heartbeatTimer;
private bool _receivedAck;
private IPEndPoint? _udpEp;
public uint Ssrc { get; private set; }
public string Ip { get; private set; } = string.Empty;
public int Port { get; private set; } = 0;
public byte[] SecretKey { get; private set; } = Array.Empty<byte>();
public string Mode { get; private set; } = string.Empty;
public ushort Sequence { get; set; }
public uint NonceSequence { get; set; }
public uint Timestamp { get; set; }
public string MyIp { get; private set; } = string.Empty;
public ushort MyPort { get; private set; }
private bool _shouldResume;
private readonly CancellationTokenSource _stopCancellationSource;
private readonly CancellationToken _stopCancellationToken;
public bool Stopped => _stopCancellationToken.IsCancellationRequested;
public event Func<VoiceGateway, Task> OnClosed = delegate { return Task.CompletedTask; };
public VoiceGateway(ulong guildId, ulong userId, string session, string token, string endpoint)
{
this._guildId = guildId;
this._userId = userId;
this._sessionId = session;
this._token = token;
this._endpoint = endpoint;
//Log.Information("g: {GuildId} u: {UserId} sess: {Session} tok: {Token} ep: {Endpoint}",
// guildId, userId, session, token, endpoint);
this._websocketUrl = new($"wss://{_endpoint.Replace(":80", "")}?v=4");
this._channel = Channel.CreateUnbounded<QueueItem>(new()
{
SingleReader = true,
SingleWriter = false,
AllowSynchronousContinuations = false,
});
ConnectingFinished = new();
_rng = new();
_ws = new();
_udpClient = new();
_stopCancellationSource = new();
_stopCancellationToken = _stopCancellationSource.Token;
_ws.PayloadReceived += _ws_PayloadReceived;
_ws.WebsocketClosed += _ws_WebsocketClosed;
}
public Task WaitForReadyAsync()
=> ConnectingFinished.Task;
private async Task SendLoop()
{
while (!_stopCancellationToken.IsCancellationRequested)
{
try
{
var qi = await _channel.Reader.ReadAsync(_stopCancellationToken);
//Log.Information("Sending payload with opcode {OpCode}", qi.Payload.OpCode);
var json = JsonConvert.SerializeObject(qi.Payload);
if (!_stopCancellationToken.IsCancellationRequested)
await _ws.SendAsync(Encoding.UTF8.GetBytes(json));
_ = Task.Run(() => qi.Result.TrySetResult(true));
}
catch (ChannelClosedException)
{
Log.Warning("Voice gateway send channel is closed");
}
}
}
private async Task _ws_PayloadReceived(byte[] arg)
{
var payload = JsonConvert.DeserializeObject<VoicePayload>(Encoding.UTF8.GetString(arg));
if (payload is null)
return;
try
{
//Log.Information("Received payload with opcode {OpCode}", payload.OpCode);
switch (payload.OpCode)
{
case VoiceOpCode.Identify:
// sent, not received.
break;
case VoiceOpCode.SelectProtocol:
// sent, not received
break;
case VoiceOpCode.Ready:
var ready = payload.Data.ToObject<VoiceReady>();
await HandleReadyAsync(ready!);
_shouldResume = true;
break;
case VoiceOpCode.Heartbeat:
// sent, not received
break;
case VoiceOpCode.SessionDescription:
var sd = payload.Data.ToObject<VoiceSessionDescription>();
await HandleSessionDescription(sd!);
break;
case VoiceOpCode.Speaking:
// ignore for now
break;
case VoiceOpCode.HeartbeatAck:
_receivedAck = true;
break;
case VoiceOpCode.Resume:
// sent, not received
break;
case VoiceOpCode.Hello:
var hello = payload.Data.ToObject<VoiceHello>();
await HandleHelloAsync(hello!);
break;
case VoiceOpCode.Resumed:
_shouldResume = true;
break;
case VoiceOpCode.ClientDisconnect:
break;
}
}
catch (Exception ex)
{
Log.Error(ex, "Error handling payload with opcode {OpCode}: {Message}", payload.OpCode, ex.Message);
}
}
private Task _ws_WebsocketClosed(string arg)
{
if (!string.IsNullOrWhiteSpace(arg))
{
Log.Warning("Voice Websocket closed: {Arg}", arg);
}
var hbt = _heartbeatTimer;
hbt?.Change(Timeout.Infinite, Timeout.Infinite);
_heartbeatTimer = null;
if (!_stopCancellationToken.IsCancellationRequested && _shouldResume)
{
_ = _ws.RunAndBlockAsync(_websocketUrl, _stopCancellationToken);
return Task.CompletedTask;
}
_ws.WebsocketClosed -= _ws_WebsocketClosed;
_ws.PayloadReceived -= _ws_PayloadReceived;
if (!_stopCancellationToken.IsCancellationRequested)
_stopCancellationSource.Cancel();
return this.OnClosed(this);
}
public void SendRtpData(byte[] rtpData, int length)
=> _udpClient.Send(rtpData, length, _udpEp);
private Task HandleSessionDescription(VoiceSessionDescription sd)
{
SecretKey = sd.SecretKey;
Mode = sd.Mode;
_ = Task.Run(() => ConnectingFinished.TrySetResult(true));
return Task.CompletedTask;
}
private Task ResumeAsync()
{
_shouldResume = false;
return SendCommandPayloadAsync(new()
{
OpCode = VoiceOpCode.Resume,
Data = JToken.FromObject(new VoiceResume
{
ServerId = this._guildId.ToString(),
SessionId = this._sessionId,
Token = this._token,
})
});
}
private async Task HandleReadyAsync(VoiceReady ready)
{
Ssrc = ready.Ssrc;
//Log.Information("Received ready {GuildId}, {Session}, {Token}", guildId, session, token);
_udpEp = new(IPAddress.Parse(ready.Ip), ready.Port);
var ssrcBytes = BitConverter.GetBytes(Ssrc);
Array.Reverse(ssrcBytes);
var ipDiscoveryData = new byte[74];
Buffer.BlockCopy(ssrcBytes, 0, ipDiscoveryData, 4, ssrcBytes.Length);
ipDiscoveryData[0] = 0x00;
ipDiscoveryData[1] = 0x01;
ipDiscoveryData[2] = 0x00;
ipDiscoveryData[3] = 0x46;
await _udpClient.SendAsync(ipDiscoveryData, ipDiscoveryData.Length, _udpEp);
while (true)
{
var buffer = _udpClient.Receive(ref _udpEp);
if (buffer.Length == 74)
{
//Log.Information("Received IP discovery data.");
var myIp = Encoding.UTF8.GetString(buffer, 8, buffer.Length - 10);
MyIp = myIp.TrimEnd('\0');
MyPort = (ushort)((buffer[^2] << 8) | buffer[^1]);
//Log.Information("{MyIp}:{MyPort}", MyIp, MyPort);
await SelectProtocol();
return;
}
//Log.Information("Received voice data");
}
}
private Task HandleHelloAsync(VoiceHello data)
{
_receivedAck = true;
_heartbeatTimer = new(async _ =>
{
await SendHeartbeatAsync();
}, default, data.HeartbeatInterval, data.HeartbeatInterval);
if (_shouldResume)
{
return ResumeAsync();
}
return IdentifyAsync();
}
private Task IdentifyAsync()
=> SendCommandPayloadAsync(new()
{
OpCode = VoiceOpCode.Identify,
Data = JToken.FromObject(new VoiceIdentify
{
ServerId = _guildId.ToString(),
SessionId = _sessionId,
Token = _token,
UserId = _userId.ToString(),
})
});
private Task SelectProtocol()
=> SendCommandPayloadAsync(new()
{
OpCode = VoiceOpCode.SelectProtocol,
Data = JToken.FromObject(new SelectProtocol
{
Protocol = "udp",
Data = new()
{
Address = MyIp,
Port = MyPort,
Mode = "xsalsa20_poly1305_lite",
}
})
});
private async Task SendHeartbeatAsync()
{
if (!_receivedAck)
{
Log.Warning("Voice gateway didn't receive HearbeatAck - closing");
var success = await _ws.CloseAsync();
if (!success)
await _ws_WebsocketClosed(null);
return;
}
_receivedAck = false;
await SendCommandPayloadAsync(new()
{
OpCode = VoiceOpCode.Heartbeat,
Data = JToken.FromObject(_rng.Next())
});
}
public Task SendSpeakingAsync(VoiceSpeaking.State speaking)
=> SendCommandPayloadAsync(new()
{
OpCode = VoiceOpCode.Speaking,
Data = JToken.FromObject(new VoiceSpeaking
{
Delay = 0,
Ssrc = Ssrc,
Speaking = (int)speaking
})
});
public Task StopAsync()
{
Started = false;
_shouldResume = false;
if (!_stopCancellationSource.IsCancellationRequested)
try { _stopCancellationSource.Cancel(); } catch { }
return _ws.CloseAsync("Stopped by the user.");
}
public Task Start()
{
Started = true;
_ = SendLoop();
return _ws.RunAndBlockAsync(_websocketUrl, _stopCancellationToken);
}
public bool Started { get; set; }
public async Task SendCommandPayloadAsync(VoicePayload payload)
{
var complete = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var queueItem = new QueueItem(payload, complete);
if (!_channel.Writer.TryWrite(queueItem))
await _channel.Writer.WriteAsync(queueItem);
await complete.Task;
}
}
}