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