375 lines
No EOL
13 KiB
C#
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;
|
|
}
|
|
}
|
|
} |