feat: identify, presence update commands
This commit is contained in:
		
							parent
							
								
									b3bf3a7c16
								
							
						
					
					
						commit
						6c335568f5
					
				
					 11 changed files with 194 additions and 68 deletions
				
			
		|  | @ -1,53 +0,0 @@ | |||
| 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; | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -1,3 +1,9 @@ | |||
| using System.Text.Json; | ||||
| using Foxcord.Gateway.Events.Commands; | ||||
| using Foxcord.Gateway.Events.Dispatch; | ||||
| using Foxcord.Rest; | ||||
| using Foxcord.Rest.Types; | ||||
| 
 | ||||
| namespace Foxcord.Gateway; | ||||
| 
 | ||||
| public partial class DiscordGatewayClient | ||||
|  | @ -11,7 +17,51 @@ public partial class DiscordGatewayClient | |||
|     private async Task HandleHeartbeatRequest(CancellationToken ct = default) | ||||
|     { | ||||
|         _logger.Information("Early heartbeat requested, sending heartbeat"); | ||||
|         await WritePacket(new GatewayPacket { Opcode = GatewayOpcode.Heartbeat, Payload = _lastSequence }, ct); | ||||
|         await SendCommandAsync(new HeartbeatCommand(_lastSequence), ct); | ||||
|         _lastHeartbeatSend = DateTimeOffset.UtcNow; | ||||
|     } | ||||
|      | ||||
|     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; | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; | |||
| using System.Net.WebSockets; | ||||
| using System.Text.Json; | ||||
| using Foxcord.Gateway.Events; | ||||
| using Foxcord.Gateway.Events.Commands; | ||||
| using Foxcord.Serialization; | ||||
| using Serilog; | ||||
| 
 | ||||
|  | @ -18,9 +19,12 @@ public partial class DiscordGatewayClient | |||
|     private readonly string _token; | ||||
|     private readonly Uri _gatewayUri; | ||||
|     private readonly GatewayIntent _intents; | ||||
|     private readonly IdentifyProperties? _properties; | ||||
|     private readonly int[]? _shardInfo; | ||||
|     private readonly PresenceUpdateCommand? _initialPresence; | ||||
|     private readonly JsonSerializerOptions _jsonSerializerOptions = JsonSerializerExtensions.CreateSerializer(); | ||||
| 
 | ||||
|     private long? _lastSequence = null; | ||||
|     private long? _lastSequence; | ||||
|     private DateTimeOffset _lastHeartbeatSend = DateTimeOffset.UnixEpoch; | ||||
|     private DateTimeOffset _lastHeartbeatAck = DateTimeOffset.UnixEpoch; | ||||
|     public TimeSpan Latency => _lastHeartbeatAck - _lastHeartbeatSend; | ||||
|  | @ -35,6 +39,9 @@ public partial class DiscordGatewayClient | |||
|         }; | ||||
|         _gatewayUri = uriBuilder.Uri; | ||||
|         _intents = opts.Intents; | ||||
|         _properties = opts.IdentifyProperties; | ||||
|         _shardInfo = opts.Shards; | ||||
|         _initialPresence = opts.InitialPresence; | ||||
|     } | ||||
| 
 | ||||
|     public ConnectionStatus Status { get; private set; } = ConnectionStatus.Dead; | ||||
|  | @ -44,7 +51,6 @@ public partial class DiscordGatewayClient | |||
|     /// <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) | ||||
|  | @ -59,7 +65,7 @@ public partial class DiscordGatewayClient | |||
| 
 | ||||
|             using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); | ||||
|             cts.CancelAfter(TimeSpan.FromSeconds(30)); | ||||
|             var (rawHelloPacketType, rawHelloPacket) = await ReadPacket(cts.Token); | ||||
|             var (rawHelloPacketType, rawHelloPacket) = await ReadPacketAsync(cts.Token); | ||||
|             if (rawHelloPacketType == WebSocketMessageType.Close) | ||||
|                 throw new DiscordGatewayRequestError("First packet received was a close message"); | ||||
| 
 | ||||
|  | @ -73,10 +79,17 @@ public partial class DiscordGatewayClient | |||
|             var __ = ReceiveLoopAsync(ct); | ||||
| 
 | ||||
|             _logger.Debug("Sending IDENTIFY"); | ||||
|             await WritePacket(new GatewayPacket | ||||
|             await WritePacketAsync(new GatewayPacket | ||||
|             { | ||||
|                 Opcode = GatewayOpcode.Identify, | ||||
|                 Payload = new IdentifyEvent { Token = _token, Intents = _intents } | ||||
|                 Payload = new IdentifyEvent | ||||
|                 { | ||||
|                     Token = _token, | ||||
|                     Intents = _intents, | ||||
|                     Properties = _properties ?? new IdentifyProperties(), | ||||
|                     Shards = _shardInfo, | ||||
|                     Presence = _initialPresence?.ToPayload() | ||||
|                 } | ||||
|             }, ct); | ||||
|         } | ||||
|         catch (Exception e) | ||||
|  | @ -98,7 +111,7 @@ public partial class DiscordGatewayClient | |||
|         { | ||||
|             _logger.Debug("Sending heartbeat with sequence {Sequence}", _lastSequence); | ||||
|             _lastHeartbeatSend = DateTimeOffset.UtcNow; | ||||
|             await WritePacket(new GatewayPacket { Opcode = GatewayOpcode.Heartbeat, Payload = _lastSequence }, ct); | ||||
|             await SendCommandAsync(new HeartbeatCommand(_lastSequence), ct); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|  | @ -108,7 +121,7 @@ public partial class DiscordGatewayClient | |||
|         { | ||||
|             try | ||||
|             { | ||||
|                 var (type, packet) = await ReadPacket(ct); | ||||
|                 var (type, packet) = await ReadPacketAsync(ct); | ||||
|                 if (type == WebSocketMessageType.Close || packet == null) | ||||
|                 { | ||||
|                     // TODO: close websocket | ||||
|  | @ -146,14 +159,17 @@ public partial class DiscordGatewayClient | |||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private async ValueTask WritePacket(GatewayPacket packet, CancellationToken ct = default) | ||||
|     public async ValueTask SendCommandAsync(IGatewayCommand command, CancellationToken ct = default) => | ||||
|         await WritePacketAsync(command.ToGatewayPacket(), ct); | ||||
| 
 | ||||
|     private async ValueTask WritePacketAsync(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( | ||||
|     private async ValueTask<(WebSocketMessageType type, GatewayPacket? packet)> ReadPacketAsync( | ||||
|         CancellationToken ct = default) | ||||
|     { | ||||
|         using var buf = MemoryPool<byte>.Shared.Rent(BufferSize); | ||||
|  | @ -163,10 +179,10 @@ public partial class DiscordGatewayClient | |||
|         if (res.EndOfMessage) | ||||
|             return DeserializePacket(res, buf.Memory.Span[..res.Count]); | ||||
| 
 | ||||
|         return await DeserializeMultipleBuffer(res, buf); | ||||
|         return await DeserializeMultipleBufferAsync(res, buf); | ||||
|     } | ||||
| 
 | ||||
|     private async Task<(WebSocketMessageType type, GatewayPacket packet)> DeserializeMultipleBuffer( | ||||
|     private async Task<(WebSocketMessageType type, GatewayPacket packet)> DeserializeMultipleBufferAsync( | ||||
|         ValueWebSocketReceiveResult res, IMemoryOwner<byte> buf) | ||||
|     { | ||||
|         await using var stream = new MemoryStream(BufferSize * 4); | ||||
|  | @ -228,4 +244,10 @@ public class DiscordGatewayClientOptions | |||
|     public required string Token { get; init; } | ||||
|     public required string Uri { get; init; } | ||||
|     public required GatewayIntent Intents { get; init; } | ||||
|     public IdentifyProperties? IdentifyProperties { get; init; } | ||||
|     public int? ShardId { get; init; } | ||||
|     public int? ShardCount { get; init; } | ||||
|     public PresenceUpdateCommand? InitialPresence { get; init; } | ||||
| 
 | ||||
|     internal int[]? Shards => ShardId != null && ShardCount != null ? [ShardId.Value, ShardCount.Value] : null; | ||||
| } | ||||
							
								
								
									
										13
									
								
								Foxcord/Gateway/Events/Commands/HeartbeatCommand.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								Foxcord/Gateway/Events/Commands/HeartbeatCommand.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,13 @@ | |||
| namespace Foxcord.Gateway.Events.Commands; | ||||
| 
 | ||||
| public record HeartbeatCommand(long? Sequence) : IGatewayCommand | ||||
| { | ||||
|     public GatewayPacket ToGatewayPacket() | ||||
|     { | ||||
|         return new GatewayPacket | ||||
|         { | ||||
|             Opcode = GatewayOpcode.Heartbeat, | ||||
|             Payload = Sequence | ||||
|         }; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										6
									
								
								Foxcord/Gateway/Events/Commands/IGatewayCommand.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								Foxcord/Gateway/Events/Commands/IGatewayCommand.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,6 @@ | |||
| namespace Foxcord.Gateway.Events.Commands; | ||||
| 
 | ||||
| public interface IGatewayCommand | ||||
| { | ||||
|     public GatewayPacket ToGatewayPacket(); | ||||
| } | ||||
							
								
								
									
										46
									
								
								Foxcord/Gateway/Events/Commands/PresenceUpdateCommand.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								Foxcord/Gateway/Events/Commands/PresenceUpdateCommand.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,46 @@ | |||
| using Foxcord.Models; | ||||
| 
 | ||||
| namespace Foxcord.Gateway.Events.Commands; | ||||
| 
 | ||||
| public class PresenceUpdateCommand : IGatewayCommand | ||||
| { | ||||
|     public long? Since { get; init; } | ||||
|     public Activity[] Activities { get; init; } = []; | ||||
|     public PresenceStatusType Status { get; init; } | ||||
|     public bool Afk { get; init; } | ||||
| 
 | ||||
|     public GatewayPacket ToGatewayPacket() | ||||
|     { | ||||
|         return new GatewayPacket | ||||
|         { | ||||
|             Opcode = GatewayOpcode.PresenceUpdate, | ||||
|             Payload = ToPayload() | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     public PresenceUpdatePayload ToPayload() | ||||
|     { | ||||
|         var status = Status switch | ||||
|         { | ||||
|             PresenceStatusType.Online => "online", | ||||
|             PresenceStatusType.Dnd => "dnd", | ||||
|             PresenceStatusType.Idle => "idle", | ||||
|             PresenceStatusType.Invisible => "invisible", | ||||
|             PresenceStatusType.Offline => "offline", | ||||
|             _ => throw new ArgumentOutOfRangeException() | ||||
|         }; | ||||
|          | ||||
|         return new PresenceUpdatePayload(Since, Activities, status, Afk); | ||||
|     } | ||||
| 
 | ||||
|     public record PresenceUpdatePayload(long? Since, Activity[] Activities, string Status, bool Afk); | ||||
| } | ||||
| 
 | ||||
| public enum PresenceStatusType | ||||
| { | ||||
|     Online, | ||||
|     Dnd, | ||||
|     Idle, | ||||
|     Invisible, | ||||
|     Offline | ||||
| } | ||||
|  | @ -1,3 +1,6 @@ | |||
| using System.Text.Json.Serialization; | ||||
| using Foxcord.Gateway.Events.Commands; | ||||
| 
 | ||||
| namespace Foxcord.Gateway.Events; | ||||
| 
 | ||||
| public class IdentifyEvent : IGatewayEvent | ||||
|  | @ -5,6 +8,10 @@ public class IdentifyEvent : IGatewayEvent | |||
|     public required string Token { get; init; } | ||||
|     public IdentifyProperties Properties { get; init; } = new(); | ||||
|     public GatewayIntent Intents { get; init; } | ||||
|     public PresenceUpdateCommand.PresenceUpdatePayload? Presence { get; init; } | ||||
| 
 | ||||
|     [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||||
|     public int[]? Shards { get; init; } | ||||
| } | ||||
| 
 | ||||
| public class IdentifyProperties | ||||
|  |  | |||
							
								
								
									
										24
									
								
								Foxcord/Models/Activity.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								Foxcord/Models/Activity.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,24 @@ | |||
| namespace Foxcord.Models; | ||||
| 
 | ||||
| public record Activity( | ||||
|     string Name, | ||||
|     ActivityType Type, | ||||
|     string? Url = null, | ||||
|     DateTimeOffset CreatedAt = default, | ||||
|     ActivityTimestamps? Timestamps = null, | ||||
|     ActivityEmoji? Emoji = null, | ||||
|     string? State = null); | ||||
| 
 | ||||
| public record ActivityTimestamps(int? Start = null, int? End = null); | ||||
| 
 | ||||
| public record ActivityEmoji(string Name, Snowflake? Id = null, bool? Animated = null); | ||||
| 
 | ||||
| public enum ActivityType | ||||
| { | ||||
|     Playing = 0, | ||||
|     Streaming = 1, | ||||
|     Listening = 2, | ||||
|     Watching = 3, | ||||
|     Custom = 4, | ||||
|     Competing = 5, | ||||
| } | ||||
|  | @ -2,7 +2,7 @@ using System.Text.Json.Serialization; | |||
| 
 | ||||
| namespace Foxcord.Models; | ||||
| 
 | ||||
| public class User | ||||
| public record User | ||||
| { | ||||
|     public Snowflake Id { get; init; } | ||||
|     public required string Username { get; init; } | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue