This commit is contained in:
sam 2024-09-03 00:07:12 +02:00
commit b3bf3a7c16
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
43 changed files with 2057 additions and 0 deletions

View file

@ -0,0 +1,53 @@
using System.Text.Json;
using Foxcord.Gateway.Events.Dispatch;
using Foxcord.Rest;
using Foxcord.Rest.Types;
namespace Foxcord.Gateway;
public partial class DiscordGatewayClient
{
private IDispatch ParseDispatchEvent(string rawType, JsonElement rawPayload)
{
switch (rawType)
{
case DispatchEventTypeName.Ready:
return rawPayload.Deserialize<ReadyEvent>(_jsonSerializerOptions)!;
case DispatchEventTypeName.GuildCreate:
return rawPayload.Deserialize<GuildCreateEvent>(_jsonSerializerOptions)!;
case DispatchEventTypeName.MessageCreate:
return rawPayload.Deserialize<MessageCreateEvent>(_jsonSerializerOptions)!;
default:
throw new ArgumentOutOfRangeException(nameof(rawType), $"Unknown dispatch event '{rawType}'");
}
}
private async Task HandleDispatch(IDispatch dispatch, CancellationToken ct = default)
{
switch (dispatch)
{
case ReadyEvent ready:
_logger.Debug("Received READY! API version: {Version}, user: {UserId}, shard: {Id}/{Total}",
ready.Version, ready.User.Id, ready.Shard.ShardId, ready.Shard.NumShards);
break;
case GuildCreateEvent guildCreate:
_logger.Debug("Received guild create for guild {Id} / {Name}", guildCreate.Id, guildCreate.Name);
break;
case MessageCreateEvent m:
_logger.Debug("Received message create from {User} in {Channel}. Content: {Content}", m.Author.Tag,
m.ChannelId, m.Content);
if (m.Content == "!ping")
{
var rest = new DiscordRestClient(_logger,
new DiscordRestClientOptions { Token = _token["Bot ".Length..] });
await rest.CreateMessageAsync(m.ChannelId,
new CreateMessageParams(Content: $"Pong! Latency: {Latency.TotalMilliseconds}ms"), ct);
}
break;
default:
_logger.Debug("Received dispatch event {DispatchType}", dispatch.GetType().Name);
break;
}
}
}

View file

@ -0,0 +1,17 @@
namespace Foxcord.Gateway;
public partial class DiscordGatewayClient
{
private void HandleHeartbeatAck()
{
_lastHeartbeatAck = DateTimeOffset.UtcNow;
_logger.Verbose("Received heartbeat ACK after {Latency}", _lastHeartbeatAck - _lastHeartbeatSend);
}
private async Task HandleHeartbeatRequest(CancellationToken ct = default)
{
_logger.Information("Early heartbeat requested, sending heartbeat");
await WritePacket(new GatewayPacket { Opcode = GatewayOpcode.Heartbeat, Payload = _lastSequence }, ct);
_lastHeartbeatSend = DateTimeOffset.UtcNow;
}
}

View file

@ -0,0 +1,231 @@
using System.Buffers;
using System.Diagnostics.CodeAnalysis;
using System.Net.WebSockets;
using System.Text.Json;
using Foxcord.Gateway.Events;
using Foxcord.Serialization;
using Serilog;
namespace Foxcord.Gateway;
public partial class DiscordGatewayClient
{
private const int GatewayVersion = 10;
private const int BufferSize = 64 * 1024;
private readonly ILogger _logger;
private ClientWebSocket? _ws;
private readonly string _token;
private readonly Uri _gatewayUri;
private readonly GatewayIntent _intents;
private readonly JsonSerializerOptions _jsonSerializerOptions = JsonSerializerExtensions.CreateSerializer();
private long? _lastSequence = null;
private DateTimeOffset _lastHeartbeatSend = DateTimeOffset.UnixEpoch;
private DateTimeOffset _lastHeartbeatAck = DateTimeOffset.UnixEpoch;
public TimeSpan Latency => _lastHeartbeatAck - _lastHeartbeatSend;
public DiscordGatewayClient(ILogger logger, DiscordGatewayClientOptions opts)
{
_logger = logger.ForContext<DiscordGatewayClient>();
_token = $"Bot {opts.Token}";
var uriBuilder = new UriBuilder(opts.Uri)
{
Query = $"v={GatewayVersion}&encoding=json"
};
_gatewayUri = uriBuilder.Uri;
_intents = opts.Intents;
}
public ConnectionStatus Status { get; private set; } = ConnectionStatus.Dead;
/// <summary>
/// Connects to the gateway. This method returns after a connection is established.
/// <c>ct</c> is stored by the client and cancelling it will close the connection.
/// The caller must pause indefinitely or the bot will shut down immediately.
/// </summary>
[MemberNotNull("_ws")]
public async Task ConnectAsync(CancellationToken ct = default)
{
if (Status != ConnectionStatus.Dead)
throw new DiscordGatewayRequestError(
"Gateway is connecting or connected, only one concurrent connection allowed.");
try
{
_ws = new ClientWebSocket();
Status = ConnectionStatus.Connecting;
await _ws.ConnectAsync(_gatewayUri, ct);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(30));
var (rawHelloPacketType, rawHelloPacket) = await ReadPacket(cts.Token);
if (rawHelloPacketType == WebSocketMessageType.Close)
throw new DiscordGatewayRequestError("First packet received was a close message");
if (!TryDeserializeEvent(rawHelloPacket, out var rawHelloEvent))
throw new DiscordGatewayRequestError("First packet was not a valid event");
if (rawHelloEvent is not HelloEvent hello)
throw new DiscordGatewayRequestError("First event was not a HELLO event");
_logger.Debug("Received HELLO, heartbeat interval is {HeartbeatInterval}", hello.HeartbeatInterval);
var _ = HeartbeatLoopAsync(hello.HeartbeatInterval, ct);
var __ = ReceiveLoopAsync(ct);
_logger.Debug("Sending IDENTIFY");
await WritePacket(new GatewayPacket
{
Opcode = GatewayOpcode.Identify,
Payload = new IdentifyEvent { Token = _token, Intents = _intents }
}, ct);
}
catch (Exception e)
{
_logger.Error(e, "Error connecting to gateway");
Status = ConnectionStatus.Dead;
_ws = null;
throw;
}
}
private async Task HeartbeatLoopAsync(int heartbeatInterval, CancellationToken ct = default)
{
var delay = TimeSpan.FromMilliseconds(heartbeatInterval * Random.Shared.NextDouble());
_logger.Debug("Waiting {Delay} before sending first heartbeat", delay);
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(heartbeatInterval));
while (await timer.WaitForNextTickAsync(ct))
{
_logger.Debug("Sending heartbeat with sequence {Sequence}", _lastSequence);
_lastHeartbeatSend = DateTimeOffset.UtcNow;
await WritePacket(new GatewayPacket { Opcode = GatewayOpcode.Heartbeat, Payload = _lastSequence }, ct);
}
}
private async Task ReceiveLoopAsync(CancellationToken ct = default)
{
while (!ct.IsCancellationRequested)
{
try
{
var (type, packet) = await ReadPacket(ct);
if (type == WebSocketMessageType.Close || packet == null)
{
// TODO: close websocket
return;
}
_ = ReceiveAsync(packet, ct);
}
catch (Exception e)
{
_logger.Error(e, "Error while receiving data");
}
}
async Task ReceiveAsync(GatewayPacket packet, CancellationToken ct2 = default)
{
if (!TryDeserializeEvent(packet, out var gatewayEvent))
{
_logger.Debug("Event {EventType} didn't have payload", packet.Opcode);
return;
}
switch (gatewayEvent)
{
case HeartbeatEvent:
await HandleHeartbeatRequest(ct2);
break;
case HeartbeatAckEvent:
HandleHeartbeatAck();
break;
case DispatchEvent dispatch:
await HandleDispatch(dispatch.Payload, ct2);
break;
}
}
}
private async ValueTask WritePacket(GatewayPacket packet, CancellationToken ct = default)
{
using var buf = MemoryPool<byte>.Shared.Rent(BufferSize);
var json = JsonSerializer.SerializeToUtf8Bytes(packet, _jsonSerializerOptions);
await _ws!.SendAsync(json.AsMemory(), WebSocketMessageType.Text, true, ct);
}
private async ValueTask<(WebSocketMessageType type, GatewayPacket? packet)> ReadPacket(
CancellationToken ct = default)
{
using var buf = MemoryPool<byte>.Shared.Rent(BufferSize);
var res = await _ws!.ReceiveAsync(buf.Memory, ct);
if (res.MessageType == WebSocketMessageType.Close) return (res.MessageType, null);
if (res.EndOfMessage)
return DeserializePacket(res, buf.Memory.Span[..res.Count]);
return await DeserializeMultipleBuffer(res, buf);
}
private async Task<(WebSocketMessageType type, GatewayPacket packet)> DeserializeMultipleBuffer(
ValueWebSocketReceiveResult res, IMemoryOwner<byte> buf)
{
await using var stream = new MemoryStream(BufferSize * 4);
stream.Write(buf.Memory.Span.Slice(0, res.Count));
while (!res.EndOfMessage)
{
res = await _ws!.ReceiveAsync(buf.Memory, default);
stream.Write(buf.Memory.Span.Slice(0, res.Count));
}
return DeserializePacket(res, stream.GetBuffer().AsSpan(0, (int)stream.Length));
}
private (WebSocketMessageType type, GatewayPacket packet) DeserializePacket(
ValueWebSocketReceiveResult res, Span<byte> span) => (res.MessageType,
JsonSerializer.Deserialize<GatewayPacket>(span, _jsonSerializerOptions)!);
private bool TryDeserializeEvent(GatewayPacket? packet, [NotNullWhen(true)] out IGatewayEvent? gatewayEvent)
{
gatewayEvent = null;
if (packet == null) return false;
var payload = packet.Payload is JsonElement element ? element : default;
switch (packet.Opcode)
{
case GatewayOpcode.Hello:
gatewayEvent = payload.Deserialize<HelloEvent>(_jsonSerializerOptions)!;
break;
case GatewayOpcode.Dispatch:
_lastSequence = packet.Sequence;
gatewayEvent = new DispatchEvent { Payload = ParseDispatchEvent(packet.EventType!, payload) };
break;
case GatewayOpcode.Heartbeat:
gatewayEvent = new HeartbeatEvent();
break;
case GatewayOpcode.Reconnect:
case GatewayOpcode.InvalidSession:
throw new NotImplementedException();
case GatewayOpcode.HeartbeatAck:
gatewayEvent = new HeartbeatAckEvent();
break;
default:
throw new ArgumentOutOfRangeException();
}
return true;
}
public enum ConnectionStatus
{
Dead,
Connecting,
Connected
}
}
public class DiscordGatewayClientOptions
{
public required string Token { get; init; }
public required string Uri { get; init; }
public required GatewayIntent Intents { get; init; }
}

View file

@ -0,0 +1,3 @@
namespace Foxcord.Gateway;
public class DiscordGatewayRequestError(string message) : Exception(message);

View file

@ -0,0 +1,9 @@
using Foxcord.Models;
namespace Foxcord.Gateway.Events.Dispatch;
public class GuildCreateEvent : Guild, IDispatch
{
public bool Unavailable { get; init; } = false;
public int MemberCount { get; init; }
}

View file

@ -0,0 +1,13 @@
namespace Foxcord.Gateway.Events.Dispatch;
public interface IDispatch
{
}
public static class DispatchEventTypeName
{
public const string Ready = "READY";
public const string GuildCreate = "GUILD_CREATE";
public const string MessageCreate = "MESSAGE_CREATE";
}

View file

@ -0,0 +1,8 @@
using Foxcord.Models;
namespace Foxcord.Gateway.Events.Dispatch;
public class MessageCreateEvent : Message, IDispatch
{
}

View file

@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
using Foxcord.Models;
namespace Foxcord.Gateway.Events.Dispatch;
public class ReadyEvent : IDispatch
{
[JsonPropertyName("v")] public int Version { get; init; }
public required User User { get; init; }
public string? SessionId { get; init; }
public string? ResumeGatewayUrl { get; init; }
[JsonPropertyName("shard")] public int[]? RawShard { private get; init; }
[JsonIgnore] public ShardInfo Shard => new(RawShard?[0] ?? 0, RawShard?[1] ?? 1);
[JsonIgnore] public bool CanResume => !string.IsNullOrEmpty(SessionId) && !string.IsNullOrEmpty(ResumeGatewayUrl);
}
public record ShardInfo(int ShardId, int NumShards);

View file

@ -0,0 +1,8 @@
using Foxcord.Gateway.Events.Dispatch;
namespace Foxcord.Gateway.Events;
internal class DispatchEvent : IGatewayEvent
{
public required IDispatch Payload { get; init; }
}

View file

@ -0,0 +1,5 @@
namespace Foxcord.Gateway.Events;
internal class HeartbeatEvent : IGatewayEvent;
internal class HeartbeatAckEvent : IGatewayEvent;

View file

@ -0,0 +1,6 @@
namespace Foxcord.Gateway.Events;
internal class HelloEvent : IGatewayEvent
{
public int HeartbeatInterval { get; init; }
}

View file

@ -0,0 +1,6 @@
namespace Foxcord.Gateway.Events;
internal interface IGatewayEvent
{
}

View file

@ -0,0 +1,15 @@
namespace Foxcord.Gateway.Events;
public class IdentifyEvent : IGatewayEvent
{
public required string Token { get; init; }
public IdentifyProperties Properties { get; init; } = new();
public GatewayIntent Intents { get; init; }
}
public class IdentifyProperties
{
public string Os { get; init; } = "Linux";
public string Browser { get; init; } = "Foxcord";
public string Device { get; init; } = "Foxcord";
}

View file

@ -0,0 +1,22 @@
namespace Foxcord.Gateway;
[Flags]
public enum GatewayIntent
{
Guilds = 1 << 0,
GuildMembers = 1 << 1,
GuildBans = 1 << 2,
GuildEmojis = 1 << 3,
GuildIntegrations = 1 << 4,
GuildWebhooks = 1 << 5,
GuildInvites = 1 << 6,
GuildVoiceStates = 1 << 7,
GuildPresences = 1 << 8,
GuildMessages = 1 << 9,
GuildMessageReactions = 1 << 10,
GuildMessageTyping = 1 << 11,
DirectMessages = 1 << 12,
DirectMessageReactions = 1 << 13,
DirectMessageTyping = 1 << 14,
MessageContent = 1 << 15,
}

View file

@ -0,0 +1,33 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Foxcord.Gateway;
public record GatewayPacket
{
[JsonPropertyName("op")] public GatewayOpcode Opcode { get; init; }
[JsonPropertyName("d")] public object? Payload { get; init; }
[JsonPropertyName("s")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? Sequence { get; init; }
[JsonPropertyName("t")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? EventType { get; init; }
}
public enum GatewayOpcode
{
Dispatch = 0,
Heartbeat = 1,
Identify = 2,
PresenceUpdate = 3,
VoiceStateUpdate = 4,
Resume = 6,
Reconnect = 7,
RequestGuildMembers = 8,
InvalidSession = 9,
Hello = 10,
HeartbeatAck = 11
}

View file

@ -0,0 +1,8 @@
using Foxcord.Models;
namespace Foxcord.Gateway.Types;
public class UnavailableGuild
{
public Snowflake Id { get; init; }
}