Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
d1761a6
basic structure
Plerx2493 Jul 5, 2024
e9b4b55
Improve Bindings, implement VerifySignature and implement DiscordHttp…
Plerx2493 Jul 11, 2024
02ec5df
Merge branch 'refs/heads/master' into plerx/dev/HttpInteractions
Plerx2493 Jul 11, 2024
7f2b391
Add http interaction injection
Plerx2493 Jul 11, 2024
69502d8
Respond to pings
Plerx2493 Jul 11, 2024
d57ccd1
Correctly parse the interaction
Plerx2493 Jul 11, 2024
4dea04c
remove nested sln
Plerx2493 Jul 11, 2024
cc5a953
Make interaction cancelble to cancel on http request cancelation
Plerx2493 Jul 12, 2024
9b53d73
Add AspNetCore extension and add verifykey to DiscordApplication
Plerx2493 Jul 12, 2024
97e02c5
Add missing xml docs
Plerx2493 Jul 12, 2024
0780610
fix formatting, project name and csproj config
Plerx2493 Jul 12, 2024
e4fb171
Add PackageId
Plerx2493 Jul 12, 2024
01b075b
Remove RootNamespace from csproj
Plerx2493 Jul 12, 2024
2716487
Maybe write the bytes directly, untested
Plerx2493 Jul 12, 2024
1dfef85
Apply namespace rename and remove unused usings
Plerx2493 Jul 12, 2024
73b98d2
Package description and tags and also reword method description
Plerx2493 Jul 17, 2024
4370c19
explicitly add outputtype and add another package tag
Plerx2493 Jul 17, 2024
555165c
Better exception if discord closed the connection
Plerx2493 Jul 17, 2024
2dbe5fb
Merge branch 'refs/heads/master' into plerx/dev/HttpInteractions
Plerx2493 Jul 17, 2024
2859fc2
fix merge mistake
Plerx2493 Jul 17, 2024
787eec6
remove NL
Plerx2493 Jul 17, 2024
3d5f2f3
Add CancellationToken in HandleDiscordInteractionAsync in aspnet package
Plerx2493 Jul 17, 2024
d2e2f83
another missing ct
Plerx2493 Jul 17, 2024
96ad68c
and another ct
Plerx2493 Jul 17, 2024
4b17461
Take ArraySegments to allow more optimizations
Plerx2493 Jul 17, 2024
623e752
XMLDocs and formatting
Plerx2493 Jul 17, 2024
948d790
add mising xmldoc
Plerx2493 Jul 18, 2024
96c1765
Parse the interaction like we do it in D*spatch
Plerx2493 Jul 18, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Library</OutputType>
<PackageId>DSharpPlus.HttpInteractions.AspNetCore</PackageId>
<Description>A package to easily use http based discord interactions with DSharpPlus in a Asp.Net project</Description>
<PackageTags>$(PackageTags), interactions, slash-commands, http-interactions</PackageTags>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>true</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\DSharpPlus\DSharpPlus.csproj"/>
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using System.Buffers;
using System.Diagnostics.CodeAnalysis;
using System.Net.Mime;

using DSharpPlus.Net.HttpInteractions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Primitives;
using Microsoft.Net.Http.Headers;

namespace DSharpPlus.HttpInteractions.AspNetCore;

public static class EndpointRouteBuilderExtensions
{
/// <summary>
/// Registers an endpoint to handle HTTP-based interactions from Discord
/// </summary>
/// <returns>A <see cref="RouteHandlerBuilder"/> that can be used to further customize the endpoint.</returns>
public static RouteHandlerBuilder AddDiscordHttpInteractions
(
this IEndpointRouteBuilder builder,
[StringSyntax("Route")] string url = "/interactions"
)
=> builder.MapPost(url, HandleDiscordInteractionAsync);

private static async Task HandleDiscordInteractionAsync(HttpContext httpContext, CancellationToken cancellationToken, [FromServices] DiscordClient client)
{
if (!httpContext.Request.Headers.TryGetValue(HeaderNames.ContentLength, out StringValues lengthString)
|| !int.TryParse(lengthString, out int length))
{
httpContext.Response.StatusCode = 400;
return;
}

byte[] bodyBuffer = ArrayPool<byte>.Shared.Rent(length);
await httpContext.Request.Body.ReadExactlyAsync(bodyBuffer.AsMemory(..length), cancellationToken);

if (!TryExtractHeaders(httpContext.Request.Headers, out string? timestamp, out string? key))
{
httpContext.Response.StatusCode = 401;
return;
}

if (!DiscordHeaders.VerifySignature(bodyBuffer.AsSpan(..length), timestamp!, key!, client.CurrentApplication.VerifyKey))
{
httpContext.Response.StatusCode = 401;
return;
}

ArraySegment<byte> body = new(bodyBuffer, 0, length);

byte[] result = await client.HandleHttpInteractionAsync(body, cancellationToken);

ArrayPool<byte>.Shared.Return(bodyBuffer);

httpContext.Response.StatusCode = 200;
httpContext.Response.ContentLength = result.Length;
httpContext.Response.ContentType = MediaTypeNames.Application.Json;

await httpContext.Response.Body.WriteAsync(result, cancellationToken);
}

public static bool TryExtractHeaders(IDictionary<string, StringValues> headers, out string? timestamp, out string? key)
{
timestamp = null;
key = null;
if (headers.TryGetValue(DiscordHeaders.TimestampHeaderName, out StringValues svTimestamp))
{
timestamp = svTimestamp;
}

if (headers.TryGetValue(DiscordHeaders.SignatureHeaderName, out StringValues svKey))
{
key = svKey;
}

return timestamp is not null && key is not null;
}
}
6 changes: 6 additions & 0 deletions DSharpPlus.sln
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DSharpPlus.Lavalink", "obso
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DSharpPlus.SlashCommands", "obsolete\DSharpPlus.SlashCommands\DSharpPlus.SlashCommands.csproj", "{C967DD7B-9CDE-47AF-B35A-875AF547DDDA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DSharpPlus.HttpInteractions.AspNetCore", "DSharpPlus.HttpInteractions.AspNetCore\DSharpPlus.HttpInteractions.AspNetCore.csproj", "{0A1C0426-650F-4DAD-84B0-2191B94FDE50}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -102,6 +104,10 @@ Global
{C967DD7B-9CDE-47AF-B35A-875AF547DDDA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C967DD7B-9CDE-47AF-B35A-875AF547DDDA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C967DD7B-9CDE-47AF-B35A-875AF547DDDA}.Release|Any CPU.Build.0 = Release|Any CPU
{0A1C0426-650F-4DAD-84B0-2191B94FDE50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0A1C0426-650F-4DAD-84B0-2191B94FDE50}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0A1C0426-650F-4DAD-84B0-2191B94FDE50}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0A1C0426-650F-4DAD-84B0-2191B94FDE50}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
62 changes: 61 additions & 1 deletion DSharpPlus/Clients/DiscordClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

namespace DSharpPlus;
Expand Down Expand Up @@ -917,6 +917,66 @@ private async IAsyncEnumerable<DiscordGuild> GetGuildsInternalAsync

#endregion

/// <summary>
/// This method is used to inject interactions into the client which are coming from http webhooks.
/// </summary>
/// <param name="body">Body of the http request. Should be UTF8 encoded</param>
/// <param name="cancellationToken">Token to cancel the interaction when the http request was canceled</param>
/// <returns>Returns the body which should be returned to the http request</returns>
/// <exception cref="TaskCanceledException">Thrown when the passed cancellation token was canceled</exception>
public async Task<byte[]> HandleHttpInteractionAsync(ArraySegment<byte> body, CancellationToken cancellationToken = default)
{
string bodyString = Encoding.UTF8.GetString(body);

JObject data = JObject.Parse(bodyString);

DiscordHttpInteraction? interaction = data.ToDiscordObject<DiscordHttpInteraction>();

if (interaction is null)
{
throw new ArgumentException("Unable to parse provided request body to DiscordHttpInteraction");
}

if (interaction.Type is DiscordInteractionType.Ping)
{
DiscordInteractionResponsePayload responsePayload = new() {Type = DiscordInteractionResponseType.Pong};
string responseString = DiscordJson.SerializeObject(responsePayload);
byte[] responseBytes = Encoding.UTF8.GetBytes(responseString);

return responseBytes;
}

cancellationToken.Register(() => interaction.Cancel());

ulong? guildId = (ulong?)data["guild_id"];
ulong channelId = (ulong)data["channel_id"];

JToken rawMember = data["member"];
TransportMember? transportMember = null;
TransportUser transportUser;
if (rawMember != null)
{
transportMember = data["member"].ToDiscordObject<TransportMember>();
transportUser = transportMember.User;
}
else
{
transportUser = data["user"].ToDiscordObject<TransportUser>();
}

JToken? rawChannel = data["channel"];
DiscordChannel? channel = null;
if (rawChannel is not null)
{
channel = rawChannel.ToDiscordObject<DiscordChannel>();
channel.Discord = this;
}

await OnInteractionCreateAsync(guildId, channelId, transportUser, transportMember, channel, interaction);

return await interaction.GetResponseAsync();
}

#region Internal Caching Methods

internal DiscordThreadChannel? InternalGetCachedThread(ulong threadId)
Expand Down
1 change: 1 addition & 0 deletions DSharpPlus/DSharpPlus.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<Description>A C# API for Discord based off DiscordSharp, but rewritten to fit the API standards.</Description>
<PackageTags>$(PackageTags), webhooks</PackageTags>
<IsPackable>true</IsPackable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.HighPerformance" />
Expand Down
72 changes: 72 additions & 0 deletions DSharpPlus/Entities/Interaction/DiscordHttpInteraction.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

using DSharpPlus.Net.Abstractions;
using DSharpPlus.Net.Serialization;
using Newtonsoft.Json;

namespace DSharpPlus.Entities;

public class DiscordHttpInteraction : DiscordInteraction
{
[JsonIgnore]
internal readonly TaskCompletionSource taskCompletionSource = new();

[JsonIgnore]
internal byte[] response;

internal bool Cancel() => this.taskCompletionSource.TrySetCanceled();

internal async Task<byte[]> GetResponseAsync()
{
await this.taskCompletionSource.Task;

return this.response;
}

/// <inheritdoc/>
public override Task CreateResponseAsync(DiscordInteractionResponseType type, DiscordInteractionResponseBuilder? builder = null)
{
if (this.taskCompletionSource.Task.IsCanceled)
{
throw new InvalidOperationException(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't necessarily indicative of Discord closing the connection though?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cancelation token should be tied to the http connection. Maybe i should add additional docs, in aspnet you get a ct which is cancled when the request is cancled

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but in general it can be that the dev cancels the interaction with other intentions

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think velvet means that the http connection could also be closed due to other reasons, such as a random network drop?

Copy link
Member Author

@Plerx2493 Plerx2493 Jul 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is likely...

I think its the common case.

"Discord closed the connection. This is likely due to exeeding the limit of 3 seconds to the response.");
}

if (this.ResponseState is not DiscordInteractionResponseState.Unacknowledged)
{
throw new InvalidOperationException("A response has already been made to this interaction.");
}

this.ResponseState = type == DiscordInteractionResponseType.DeferredChannelMessageWithSource
? DiscordInteractionResponseState.Deferred
: DiscordInteractionResponseState.Replied;

DiscordInteractionResponsePayload payload = new()
{
Type = type,
Data = builder is not null
? new DiscordInteractionApplicationCommandCallbackData
{
Content = builder.Content,
Title = builder.Title,
CustomId = builder.CustomId,
Embeds = builder.Embeds,
IsTTS = builder.IsTTS,
Mentions = new DiscordMentions(builder.Mentions ?? Mentions.All, builder.Mentions?.Any() ?? false),
Flags = builder.Flags,
Components = builder.Components,
Choices = builder.Choices,
Poll = builder.Poll?.BuildInternal(),
}
: null
};

this.response = Encoding.UTF8.GetBytes(DiscordJson.SerializeObject(payload));
this.taskCompletionSource.SetResult();

return Task.CompletedTask;
}
}
6 changes: 3 additions & 3 deletions DSharpPlus/Entities/Interaction/DiscordInteraction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ namespace DSharpPlus.Entities;
/// <summary>
/// Represents an interaction that was invoked.
/// </summary>
public sealed class DiscordInteraction : SnowflakeObject
public class DiscordInteraction : SnowflakeObject
{
/// <summary>
/// Gets the response state of the interaction.
/// </summary>
[JsonIgnore]
public DiscordInteractionResponseState ResponseState { get; private set; }
public DiscordInteractionResponseState ResponseState { get; protected set; }

/// <summary>
/// Gets the type of interaction invoked.
Expand Down Expand Up @@ -146,7 +146,7 @@ public DiscordChannel Channel
/// </summary>
/// <param name="type">The type of the response.</param>
/// <param name="builder">The data, if any, to send.</param>
public async Task CreateResponseAsync(DiscordInteractionResponseType type, DiscordInteractionResponseBuilder builder = null)
public virtual async Task CreateResponseAsync(DiscordInteractionResponseType type, DiscordInteractionResponseBuilder builder = null)
{
if (this.ResponseState is not DiscordInteractionResponseState.Unacknowledged)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ internal class RestApplicationCommandEditPayload
public Optional<IEnumerable<DiscordApplicationIntegrationType>> InstallTypes { get; set; }
}

internal class RestInteractionResponsePayload
internal class DiscordInteractionResponsePayload
{
[JsonProperty("type", NullValueHandling = NullValueHandling.Ignore)]
public DiscordInteractionResponseType Type { get; set; }
Expand Down
50 changes: 50 additions & 0 deletions DSharpPlus/Net/HttpInteractions/DiscordHeaders.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using System;
using System.Buffers;
using System.Text;
using DSharpPlus.Entities;

namespace DSharpPlus.Net.HttpInteractions;

public class DiscordHeaders
{
/// <summary>
/// Name of the HTTP header which contains the timestamp of the signature
/// </summary>
public const string TimestampHeaderName = "x-signature-timestamp";

/// <summary>
/// Name of the HTTP header which contains the signature
/// </summary>
public const string SignatureHeaderName = "x-signature-ed25519";

/// <summary>
/// Verifies the signature of a http interaction.
/// </summary>
/// <param name="body">Raw http body</param>
/// <param name="timestamp">Timestamp header sent by discord. <see cref="TimestampHeaderName"/></param>
/// <param name="signingKey">Signing key sent by discord. <see cref="SignatureHeaderName"/></param>
/// <param name="publicKey">
/// Public key of the application this interaction was sent.
/// This key can be accessed at DiscordApplication.
/// <see cref="DiscordApplication.VerifyKey"/>
/// </param>
/// <returns>Indicates if this signature is valid.</returns>
public static bool VerifySignature(ReadOnlySpan<byte> body, string timestamp, string signingKey, string publicKey)
{
byte[] timestampBytes = Encoding.UTF8.GetBytes(timestamp);
byte[] publicKeyBytes = Convert.FromHexString(publicKey);
byte[] signatureBytes = Convert.FromHexString(signingKey);

int messageLength = body.Length + timestampBytes.Length;
byte[] message = ArrayPool<byte>.Shared.Rent(messageLength);

timestampBytes.CopyTo(message, 0);
body.CopyTo(message.AsSpan(timestampBytes.Length));

bool result = Ed25519.TryVerifySignature(message.AsSpan(..messageLength), publicKeyBytes.AsSpan(), signatureBytes.AsSpan());

ArrayPool<byte>.Shared.Return(message);

return result;
}
}
43 changes: 43 additions & 0 deletions DSharpPlus/Net/HttpInteractions/ED25519.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System;
using System.Runtime.InteropServices;

namespace DSharpPlus.Net.HttpInteractions;

internal static partial class Ed25519
{
internal const int SignatureBytes = 64;
internal const int PublicKeyBytes = 32;

internal static unsafe bool TryVerifySignature(ReadOnlySpan<byte> body, ReadOnlySpan<byte> publicKey, ReadOnlySpan<byte> signature)
{
ArgumentOutOfRangeException.ThrowIfNotEqual(signature.Length, SignatureBytes);
ArgumentOutOfRangeException.ThrowIfNotEqual(publicKey.Length, PublicKeyBytes);

fixed (byte* signaturePtr = signature)
fixed (byte* messagePtr = body)
fixed (byte* publicKeyPtr = publicKey)
{
return Bindings.crypto_sign_ed25519_verify_detached(signaturePtr, messagePtr, (ulong)body.Length, publicKeyPtr) == 0;
}
}

// Ed25519.Bindings is a nested type to lazily load sodium. the native load is done by the static constructor,
// which will not be executed unless this code actually gets used. since we cannot rely on sodium being present at all
// times, it is imperative this remains a nested type.
private static partial class Bindings
{
static Bindings()
{
if (sodium_init() == -1)
{
throw new InvalidOperationException("Failed to initialize libsodium.");
}
}

[LibraryImport("sodium")]
private static unsafe partial int sodium_init();

[LibraryImport("sodium")]
internal static unsafe partial int crypto_sign_ed25519_verify_detached(byte* signature, byte* message, ulong messageLength, byte* publicKey);
}
}
Loading