From f6ef0a79ecabd4c20ca5e88a1e96461f00c3c513 Mon Sep 17 00:00:00 2001
From: 0x0ACB <11277588+0x0ACB@users.noreply.github.com>
Date: Mon, 21 Apr 2025 17:00:05 +0200
Subject: [PATCH] Add NetCord.Hosting.AzureFunctions to support serverless bots
via azure functions
---
.gitignore | 4 +-
.../DiscordInteractionsHandler.cs | 149 ++++++++++++++++++
.../NetCord.Hosting.AzureFunctions.csproj | 37 +++++
NetCord.slnx | 2 +
.../Interactions.cs | 16 ++
...NetCord.Test.Hosting.AzureFunctions.csproj | 42 +++++
.../Program.cs | 23 +++
.../Properties/launchSettings.json | 9 ++
.../Properties/serviceDependencies.json | 8 +
9 files changed, 289 insertions(+), 1 deletion(-)
create mode 100644 Hosting/NetCord.Hosting.AzureFunctions/DiscordInteractionsHandler.cs
create mode 100644 Hosting/NetCord.Hosting.AzureFunctions/NetCord.Hosting.AzureFunctions.csproj
create mode 100644 Tests/NetCord.Test.Hosting.AzureFunctions/Interactions.cs
create mode 100644 Tests/NetCord.Test.Hosting.AzureFunctions/NetCord.Test.Hosting.AzureFunctions.csproj
create mode 100644 Tests/NetCord.Test.Hosting.AzureFunctions/Program.cs
create mode 100644 Tests/NetCord.Test.Hosting.AzureFunctions/Properties/launchSettings.json
create mode 100644 Tests/NetCord.Test.Hosting.AzureFunctions/Properties/serviceDependencies.json
diff --git a/.gitignore b/.gitignore
index 44c3b430e..8a0f2d39f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -364,4 +364,6 @@ FodyWeavers.xsd
# Settings file
appsettings.json
-!Documentation/**/appsettings.json
\ No newline at end of file
+!Documentation/**/appsettings.json
+host.json
+local.settings.json
\ No newline at end of file
diff --git a/Hosting/NetCord.Hosting.AzureFunctions/DiscordInteractionsHandler.cs b/Hosting/NetCord.Hosting.AzureFunctions/DiscordInteractionsHandler.cs
new file mode 100644
index 000000000..350842cb4
--- /dev/null
+++ b/Hosting/NetCord.Hosting.AzureFunctions/DiscordInteractionsHandler.cs
@@ -0,0 +1,149 @@
+using System.Buffers;
+using System.Net;
+using System.Text;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Azure.Functions.Worker.Http;
+using Microsoft.Azure.Functions.Worker;
+using Microsoft.Extensions.Options;
+using NetCord.Rest;
+using Microsoft.Extensions.DependencyInjection;
+namespace NetCord.Hosting.AzureFunctions;
+
+public static class ServiceCollectionExtensions
+{
+ ///
+ /// Adds the to the service collection.
+ ///
+ public static IServiceCollection AddDiscordInteractions(this IServiceCollection services)
+ {
+ services.AddSingleton();
+ return services;
+ }
+}
+
+public class DiscordInteractionsHandler
+{
+ private readonly IHttpInteractionHandler[] _handlers;
+ private readonly ValueTask[] _tasks;
+ private readonly HttpInteractionValidator _validator;
+ private readonly RestClient _client;
+
+ public DiscordInteractionsHandler(
+ IOptions options,
+ IEnumerable handlers,
+ RestClient client)
+ {
+ var publicKey = options.Value.PublicKey ?? throw new InvalidOperationException("PublicKey must be set in IDiscordOptions.");
+ _validator = new HttpInteractionValidator(publicKey);
+ _handlers = handlers.ToArray();
+ _tasks = new ValueTask[_handlers.Length];
+ _client = client;
+ }
+
+ ///
+ /// Handles the Discord interaction request.
+ ///
+ /// The HTTP request data.
+ /// The function context.
+ /// The result of the interaction.
+ public async Task HandleRequestAsync(HttpRequestData req, FunctionContext context)
+ {
+ var httpContext = context.GetHttpContext();
+ if (httpContext == null)
+ {
+ var res = req.CreateResponse(HttpStatusCode.InternalServerError);
+ await res.WriteStringAsync("HttpContext not available.").ConfigureAwait(false);
+ return res;
+ }
+
+ var interaction = await ParseInteractionAsync(httpContext, _validator, _client).ConfigureAwait(false);
+ if (interaction == null)
+ {
+ var res = req.CreateResponse(HttpStatusCode.Unauthorized);
+ return res;
+ }
+
+ HttpResponseData response = req.CreateResponse(HttpStatusCode.OK);
+
+ switch (interaction)
+ {
+ case Interaction realInteraction:
+ await HandleInteractionAsync(realInteraction, _handlers, _tasks).ConfigureAwait(false);
+ break;
+
+ case PingInteraction ping:
+ await ping.SendResponseAsync(InteractionCallback.Pong).ConfigureAwait(false);
+ break;
+
+ default:
+ response.StatusCode = HttpStatusCode.Unauthorized;
+ break;
+ }
+
+ return response;
+ }
+
+ private static async ValueTask ParseInteractionAsync(HttpContext context, HttpInteractionValidator validator, RestClient client)
+ {
+ var request = context.Request;
+
+ var headers = request.Headers;
+ if (!headers.TryGetValue("X-Signature-Ed25519", out var signatures) || !headers.TryGetValue("X-Signature-Timestamp", out var timestamps))
+ {
+ return null;
+ }
+
+ var timestamp = timestamps[0]!;
+ int timestampByteCount = Encoding.UTF8.GetByteCount(timestamp);
+
+ int timestampAndBodyLength = timestampByteCount + (int)request.ContentLength.GetValueOrDefault();
+
+ var timestampAndBodyArray = ArrayPool.Shared.Rent(timestampAndBodyLength);
+ var timestampAndBody = timestampAndBodyArray.AsMemory(0, timestampAndBodyLength);
+
+ Encoding.UTF8.GetBytes(timestamp, timestampAndBody.Span);
+
+ int position = timestampByteCount;
+ var body = request.Body;
+ while (position < timestampAndBodyLength)
+ {
+ position += await body.ReadAsync(timestampAndBody[position..]).ConfigureAwait(false);
+ }
+
+ if (!validator.Validate(signatures[0], timestampAndBody.Span))
+ {
+ return null;
+ }
+
+ var response = context.Response;
+ var interaction = HttpInteractionFactory.Create(timestampAndBody.Span[timestampByteCount..], async (interaction, interactionCallback, properties, cancellationToken) =>
+ {
+ using var content = interactionCallback.Serialize();
+ response.ContentType = content.Headers.ContentType!.ToString();
+ await content.CopyToAsync(response.Body, cancellationToken).ConfigureAwait(false);
+ await response.CompleteAsync().ConfigureAwait(false);
+ }, client);
+
+ ArrayPool.Shared.Return(timestampAndBodyArray);
+
+ return interaction;
+ }
+
+ private static async ValueTask HandleInteractionAsync(Interaction interaction, IHttpInteractionHandler[] handlers, ValueTask[] tasks)
+ {
+ int length = handlers.Length;
+
+ for (int i = 0; i < length; i++)
+#pragma warning disable CA2012
+ {
+ tasks[i] = handlers[i].HandleAsync(interaction);
+ }
+#pragma warning restore CA2012
+
+ for (int i = 0; i < length; i++)
+ {
+ await tasks[i].ConfigureAwait(false);
+ }
+ }
+}
+
diff --git a/Hosting/NetCord.Hosting.AzureFunctions/NetCord.Hosting.AzureFunctions.csproj b/Hosting/NetCord.Hosting.AzureFunctions/NetCord.Hosting.AzureFunctions.csproj
new file mode 100644
index 000000000..c87b1be24
--- /dev/null
+++ b/Hosting/NetCord.Hosting.AzureFunctions/NetCord.Hosting.AzureFunctions.csproj
@@ -0,0 +1,37 @@
+
+
+
+ net8.0
+ enable
+ enable
+ latest
+ true
+
+ https://github.com/NetCordDev/NetCord
+ https://netcord.dev
+ bot;discord;discord-api
+ SmallSquare.png
+ MIT
+ $(VersionPrefix)
+ alpha.363
+ The modern and fully customizable C# Discord library.
+ README.md
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/NetCord.slnx b/NetCord.slnx
index f20e12e95..a0f768a63 100644
--- a/NetCord.slnx
+++ b/NetCord.slnx
@@ -55,6 +55,7 @@
+
@@ -71,6 +72,7 @@
+
diff --git a/Tests/NetCord.Test.Hosting.AzureFunctions/Interactions.cs b/Tests/NetCord.Test.Hosting.AzureFunctions/Interactions.cs
new file mode 100644
index 000000000..12568433a
--- /dev/null
+++ b/Tests/NetCord.Test.Hosting.AzureFunctions/Interactions.cs
@@ -0,0 +1,16 @@
+using Microsoft.Azure.Functions.Worker;
+using Microsoft.Azure.Functions.Worker.Http;
+
+using NetCord.Hosting.AzureFunctions;
+
+namespace NetCord.Test.Hosting.AzureFunctions;
+
+public class Interactions(DiscordInteractionsHandler handler)
+{
+ [Function("interactions")]
+ public async Task Run([HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequestData req, FunctionContext context)
+ {
+ var res = await handler.HandleRequestAsync(req, context).ConfigureAwait(false);
+ return res;
+ }
+}
diff --git a/Tests/NetCord.Test.Hosting.AzureFunctions/NetCord.Test.Hosting.AzureFunctions.csproj b/Tests/NetCord.Test.Hosting.AzureFunctions/NetCord.Test.Hosting.AzureFunctions.csproj
new file mode 100644
index 000000000..e1a29ec26
--- /dev/null
+++ b/Tests/NetCord.Test.Hosting.AzureFunctions/NetCord.Test.Hosting.AzureFunctions.csproj
@@ -0,0 +1,42 @@
+
+
+
+ net8.0
+ v4
+ Exe
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+ PreserveNewest
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Tests/NetCord.Test.Hosting.AzureFunctions/Program.cs b/Tests/NetCord.Test.Hosting.AzureFunctions/Program.cs
new file mode 100644
index 000000000..5ea11ef7d
--- /dev/null
+++ b/Tests/NetCord.Test.Hosting.AzureFunctions/Program.cs
@@ -0,0 +1,23 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+using NetCord;
+using NetCord.Hosting;
+using NetCord.Hosting.AzureFunctions;
+using NetCord.Hosting.Rest;
+using NetCord.Hosting.Services.ApplicationCommands;
+
+var host = new HostBuilder()
+ .ConfigureFunctionsWebApplication()
+ .ConfigureServices((context, services) =>
+ {
+ services.AddDiscordRest();
+ services.AddHttpApplicationCommands();
+ services.AddDiscordInteractions();
+ })
+ .Build();
+
+host.AddSlashCommand("ping", "Ping!", () => "Pong!");
+
+host.Run();
diff --git a/Tests/NetCord.Test.Hosting.AzureFunctions/Properties/launchSettings.json b/Tests/NetCord.Test.Hosting.AzureFunctions/Properties/launchSettings.json
new file mode 100644
index 000000000..5dab0c7ee
--- /dev/null
+++ b/Tests/NetCord.Test.Hosting.AzureFunctions/Properties/launchSettings.json
@@ -0,0 +1,9 @@
+{
+ "profiles": {
+ "NetCord.Test.Hosting.AzureFunctions": {
+ "commandName": "Project",
+ "commandLineArgs": "--port 7048",
+ "launchBrowser": false
+ }
+ }
+}
\ No newline at end of file
diff --git a/Tests/NetCord.Test.Hosting.AzureFunctions/Properties/serviceDependencies.json b/Tests/NetCord.Test.Hosting.AzureFunctions/Properties/serviceDependencies.json
new file mode 100644
index 000000000..fcc92d112
--- /dev/null
+++ b/Tests/NetCord.Test.Hosting.AzureFunctions/Properties/serviceDependencies.json
@@ -0,0 +1,8 @@
+{
+ "dependencies": {
+ "storage1": {
+ "type": "storage",
+ "connectionId": "AzureWebJobsStorage"
+ }
+ }
+}
\ No newline at end of file