From 6c335568f566d5ad1ceff53b07585f351f490e67 Mon Sep 17 00:00:00 2001 From: sam Date: Tue, 3 Sep 2024 02:11:11 +0200 Subject: [PATCH] feat: identify, presence update commands --- .../.idea/{misc.xml => discord.xml} | 2 +- .../Gateway/DiscordGatewayClient.Dispatch.cs | 53 ------------------- .../Gateway/DiscordGatewayClient.Events.cs | 52 +++++++++++++++++- Foxcord/Gateway/DiscordGatewayClient.cs | 44 +++++++++++---- .../Events/Commands/HeartbeatCommand.cs | 13 +++++ .../Events/Commands/IGatewayCommand.cs | 6 +++ .../Events/Commands/PresenceUpdateCommand.cs | 46 ++++++++++++++++ Foxcord/Gateway/Events/IdentifyEvent.cs | 7 +++ Foxcord/Models/Activity.cs | 24 +++++++++ Foxcord/Models/User.cs | 2 +- FoxcordTest/Program.cs | 13 ++++- 11 files changed, 194 insertions(+), 68 deletions(-) rename .idea/.idea.Foxcord/.idea/{misc.xml => discord.xml} (73%) delete mode 100644 Foxcord/Gateway/DiscordGatewayClient.Dispatch.cs create mode 100644 Foxcord/Gateway/Events/Commands/HeartbeatCommand.cs create mode 100644 Foxcord/Gateway/Events/Commands/IGatewayCommand.cs create mode 100644 Foxcord/Gateway/Events/Commands/PresenceUpdateCommand.cs create mode 100644 Foxcord/Models/Activity.cs diff --git a/.idea/.idea.Foxcord/.idea/misc.xml b/.idea/.idea.Foxcord/.idea/discord.xml similarity index 73% rename from .idea/.idea.Foxcord/.idea/misc.xml rename to .idea/.idea.Foxcord/.idea/discord.xml index 30bab2a..d8e9561 100644 --- a/.idea/.idea.Foxcord/.idea/misc.xml +++ b/.idea/.idea.Foxcord/.idea/discord.xml @@ -1,7 +1,7 @@ - \ No newline at end of file diff --git a/Foxcord/Gateway/DiscordGatewayClient.Dispatch.cs b/Foxcord/Gateway/DiscordGatewayClient.Dispatch.cs deleted file mode 100644 index 37ae970..0000000 --- a/Foxcord/Gateway/DiscordGatewayClient.Dispatch.cs +++ /dev/null @@ -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(_jsonSerializerOptions)!; - case DispatchEventTypeName.GuildCreate: - return rawPayload.Deserialize(_jsonSerializerOptions)!; - case DispatchEventTypeName.MessageCreate: - return rawPayload.Deserialize(_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; - } - } -} \ No newline at end of file diff --git a/Foxcord/Gateway/DiscordGatewayClient.Events.cs b/Foxcord/Gateway/DiscordGatewayClient.Events.cs index 6d90c4b..4caaf22 100644 --- a/Foxcord/Gateway/DiscordGatewayClient.Events.cs +++ b/Foxcord/Gateway/DiscordGatewayClient.Events.cs @@ -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(_jsonSerializerOptions)!; + case DispatchEventTypeName.GuildCreate: + return rawPayload.Deserialize(_jsonSerializerOptions)!; + case DispatchEventTypeName.MessageCreate: + return rawPayload.Deserialize(_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; + } + } } \ No newline at end of file diff --git a/Foxcord/Gateway/DiscordGatewayClient.cs b/Foxcord/Gateway/DiscordGatewayClient.cs index 8e472b2..72c3346 100644 --- a/Foxcord/Gateway/DiscordGatewayClient.cs +++ b/Foxcord/Gateway/DiscordGatewayClient.cs @@ -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 /// ct is stored by the client and cancelling it will close the connection. /// The caller must pause indefinitely or the bot will shut down immediately. /// - [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.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.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 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; } \ No newline at end of file diff --git a/Foxcord/Gateway/Events/Commands/HeartbeatCommand.cs b/Foxcord/Gateway/Events/Commands/HeartbeatCommand.cs new file mode 100644 index 0000000..dd5f43b --- /dev/null +++ b/Foxcord/Gateway/Events/Commands/HeartbeatCommand.cs @@ -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 + }; + } +} \ No newline at end of file diff --git a/Foxcord/Gateway/Events/Commands/IGatewayCommand.cs b/Foxcord/Gateway/Events/Commands/IGatewayCommand.cs new file mode 100644 index 0000000..a7a70e4 --- /dev/null +++ b/Foxcord/Gateway/Events/Commands/IGatewayCommand.cs @@ -0,0 +1,6 @@ +namespace Foxcord.Gateway.Events.Commands; + +public interface IGatewayCommand +{ + public GatewayPacket ToGatewayPacket(); +} \ No newline at end of file diff --git a/Foxcord/Gateway/Events/Commands/PresenceUpdateCommand.cs b/Foxcord/Gateway/Events/Commands/PresenceUpdateCommand.cs new file mode 100644 index 0000000..71c31cb --- /dev/null +++ b/Foxcord/Gateway/Events/Commands/PresenceUpdateCommand.cs @@ -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 +} \ No newline at end of file diff --git a/Foxcord/Gateway/Events/IdentifyEvent.cs b/Foxcord/Gateway/Events/IdentifyEvent.cs index 7a11e8b..2648241 100644 --- a/Foxcord/Gateway/Events/IdentifyEvent.cs +++ b/Foxcord/Gateway/Events/IdentifyEvent.cs @@ -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 diff --git a/Foxcord/Models/Activity.cs b/Foxcord/Models/Activity.cs new file mode 100644 index 0000000..65c814a --- /dev/null +++ b/Foxcord/Models/Activity.cs @@ -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, +} \ No newline at end of file diff --git a/Foxcord/Models/User.cs b/Foxcord/Models/User.cs index ec0a169..055fb09 100644 --- a/Foxcord/Models/User.cs +++ b/Foxcord/Models/User.cs @@ -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; } diff --git a/FoxcordTest/Program.cs b/FoxcordTest/Program.cs index 76f237f..6b5f9f5 100644 --- a/FoxcordTest/Program.cs +++ b/FoxcordTest/Program.cs @@ -1,4 +1,7 @@ using Foxcord.Gateway; +using Foxcord.Gateway.Events; +using Foxcord.Gateway.Events.Commands; +using Foxcord.Models; using Serilog; using Foxcord.Rest; @@ -18,7 +21,15 @@ var gateway = new DiscordGatewayClient(Log.Logger, new DiscordGatewayClientOptio { Token = Environment.GetEnvironmentVariable("TOKEN")!, Uri = gatewayBot.Url, - Intents = GatewayIntent.Guilds | GatewayIntent.GuildMessages | GatewayIntent.MessageContent + Intents = GatewayIntent.Guilds | GatewayIntent.GuildMessages | GatewayIntent.MessageContent, + InitialPresence = new PresenceUpdateCommand + { + Activities = [new Activity("balls", ActivityType.Custom, State: "gay gay homosexual gay")] + }, + IdentifyProperties = new IdentifyProperties + { + Browser = "Discord iOS" + } }); await gateway.ConnectAsync();