feat: identify, presence update commands

This commit is contained in:
sam 2024-09-03 02:11:11 +02:00
parent b3bf3a7c16
commit 6c335568f5
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
11 changed files with 194 additions and 68 deletions

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="ASK" />
<option name="show" value="PROJECT_FILES" />
<option name="description" value="" />
</component>
</project>

View file

@ -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;
}
}
}

View file

@ -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;
}
}
}

View file

@ -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;
}

View 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
};
}
}

View file

@ -0,0 +1,6 @@
namespace Foxcord.Gateway.Events.Commands;
public interface IGatewayCommand
{
public GatewayPacket ToGatewayPacket();
}

View 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
}

View file

@ -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

View 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,
}

View file

@ -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; }

View file

@ -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();