init
This commit is contained in:
commit
b3bf3a7c16
43 changed files with 2057 additions and 0 deletions
53
Foxcord/Gateway/DiscordGatewayClient.Dispatch.cs
Normal file
53
Foxcord/Gateway/DiscordGatewayClient.Dispatch.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
17
Foxcord/Gateway/DiscordGatewayClient.Events.cs
Normal file
17
Foxcord/Gateway/DiscordGatewayClient.Events.cs
Normal 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;
|
||||
}
|
||||
}
|
231
Foxcord/Gateway/DiscordGatewayClient.cs
Normal file
231
Foxcord/Gateway/DiscordGatewayClient.cs
Normal 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; }
|
||||
}
|
3
Foxcord/Gateway/DiscordGatewayRequestError.cs
Normal file
3
Foxcord/Gateway/DiscordGatewayRequestError.cs
Normal file
|
@ -0,0 +1,3 @@
|
|||
namespace Foxcord.Gateway;
|
||||
|
||||
public class DiscordGatewayRequestError(string message) : Exception(message);
|
9
Foxcord/Gateway/Events/Dispatch/GuildCreateEvent.cs
Normal file
9
Foxcord/Gateway/Events/Dispatch/GuildCreateEvent.cs
Normal 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; }
|
||||
}
|
13
Foxcord/Gateway/Events/Dispatch/IDispatch.cs
Normal file
13
Foxcord/Gateway/Events/Dispatch/IDispatch.cs
Normal 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";
|
||||
}
|
8
Foxcord/Gateway/Events/Dispatch/MessageCreateEvent.cs
Normal file
8
Foxcord/Gateway/Events/Dispatch/MessageCreateEvent.cs
Normal file
|
@ -0,0 +1,8 @@
|
|||
using Foxcord.Models;
|
||||
|
||||
namespace Foxcord.Gateway.Events.Dispatch;
|
||||
|
||||
public class MessageCreateEvent : Message, IDispatch
|
||||
{
|
||||
|
||||
}
|
18
Foxcord/Gateway/Events/Dispatch/ReadyEvent.cs
Normal file
18
Foxcord/Gateway/Events/Dispatch/ReadyEvent.cs
Normal 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);
|
8
Foxcord/Gateway/Events/DispatchEvent.cs
Normal file
8
Foxcord/Gateway/Events/DispatchEvent.cs
Normal file
|
@ -0,0 +1,8 @@
|
|||
using Foxcord.Gateway.Events.Dispatch;
|
||||
|
||||
namespace Foxcord.Gateway.Events;
|
||||
|
||||
internal class DispatchEvent : IGatewayEvent
|
||||
{
|
||||
public required IDispatch Payload { get; init; }
|
||||
}
|
5
Foxcord/Gateway/Events/HeartbeatEvent.cs
Normal file
5
Foxcord/Gateway/Events/HeartbeatEvent.cs
Normal file
|
@ -0,0 +1,5 @@
|
|||
namespace Foxcord.Gateway.Events;
|
||||
|
||||
internal class HeartbeatEvent : IGatewayEvent;
|
||||
|
||||
internal class HeartbeatAckEvent : IGatewayEvent;
|
6
Foxcord/Gateway/Events/HelloEvent.cs
Normal file
6
Foxcord/Gateway/Events/HelloEvent.cs
Normal file
|
@ -0,0 +1,6 @@
|
|||
namespace Foxcord.Gateway.Events;
|
||||
|
||||
internal class HelloEvent : IGatewayEvent
|
||||
{
|
||||
public int HeartbeatInterval { get; init; }
|
||||
}
|
6
Foxcord/Gateway/Events/IGatewayEvent.cs
Normal file
6
Foxcord/Gateway/Events/IGatewayEvent.cs
Normal file
|
@ -0,0 +1,6 @@
|
|||
namespace Foxcord.Gateway.Events;
|
||||
|
||||
internal interface IGatewayEvent
|
||||
{
|
||||
|
||||
}
|
15
Foxcord/Gateway/Events/IdentifyEvent.cs
Normal file
15
Foxcord/Gateway/Events/IdentifyEvent.cs
Normal 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";
|
||||
}
|
22
Foxcord/Gateway/GatewayIntent.cs
Normal file
22
Foxcord/Gateway/GatewayIntent.cs
Normal 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,
|
||||
}
|
33
Foxcord/Gateway/GatewayPacket.cs
Normal file
33
Foxcord/Gateway/GatewayPacket.cs
Normal 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
|
||||
}
|
8
Foxcord/Gateway/Types/UnavailableGuild.cs
Normal file
8
Foxcord/Gateway/Types/UnavailableGuild.cs
Normal file
|
@ -0,0 +1,8 @@
|
|||
using Foxcord.Models;
|
||||
|
||||
namespace Foxcord.Gateway.Types;
|
||||
|
||||
public class UnavailableGuild
|
||||
{
|
||||
public Snowflake Id { get; init; }
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue