From 7992d3b40b023093a60ecd14d5741152a73bde90 Mon Sep 17 00:00:00 2001 From: Filip Hoffmann Date: Mon, 4 May 2026 14:47:07 +0200 Subject: [PATCH] http interactions example --- .../.github/workflows/test.yml | 23 ++ examples/http_interactions/.gitignore | 4 + examples/http_interactions/README.md | 24 ++ examples/http_interactions/gleam.toml | 28 +++ examples/http_interactions/manifest.toml | 49 ++++ .../src/http_interactions.gleam | 228 ++++++++++++++++++ .../test/http_interactions_test.gleam | 13 + 7 files changed, 369 insertions(+) create mode 100644 examples/http_interactions/.github/workflows/test.yml create mode 100644 examples/http_interactions/.gitignore create mode 100644 examples/http_interactions/README.md create mode 100644 examples/http_interactions/gleam.toml create mode 100644 examples/http_interactions/manifest.toml create mode 100644 examples/http_interactions/src/http_interactions.gleam create mode 100644 examples/http_interactions/test/http_interactions_test.gleam diff --git a/examples/http_interactions/.github/workflows/test.yml b/examples/http_interactions/.github/workflows/test.yml new file mode 100644 index 0000000..eb4a679 --- /dev/null +++ b/examples/http_interactions/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: test + +on: + push: + branches: + - master + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: erlef/setup-beam@v1 + with: + otp-version: "28" + gleam-version: "1.16.0" + rebar3-version: "3" + # elixir-version: "1" + - run: gleam deps download + - run: gleam test + - run: gleam format --check src test diff --git a/examples/http_interactions/.gitignore b/examples/http_interactions/.gitignore new file mode 100644 index 0000000..599be4e --- /dev/null +++ b/examples/http_interactions/.gitignore @@ -0,0 +1,4 @@ +*.beam +*.ez +/build +erl_crash.dump diff --git a/examples/http_interactions/README.md b/examples/http_interactions/README.md new file mode 100644 index 0000000..dae1f7b --- /dev/null +++ b/examples/http_interactions/README.md @@ -0,0 +1,24 @@ +# http_interactions + +[![Package Version](https://img.shields.io/hexpm/v/http_interactions)](https://hex.pm/packages/http_interactions) +[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/http_interactions/) + +```sh +gleam add http_interactions@1 +``` +```gleam +import http_interactions + +pub fn main() -> Nil { + // TODO: An example of the project in use +} +``` + +Further documentation can be found at . + +## Development + +```sh +gleam run # Run the project +gleam test # Run the tests +``` diff --git a/examples/http_interactions/gleam.toml b/examples/http_interactions/gleam.toml new file mode 100644 index 0000000..2b35f7e --- /dev/null +++ b/examples/http_interactions/gleam.toml @@ -0,0 +1,28 @@ +name = "http_interactions" +version = "1.0.0" + +# Fill out these fields if you intend to generate HTML documentation or publish +# your project to the Hex package manager. +# +# description = "" +# licences = ["Apache-2.0"] +# repository = { type = "github", user = "", repo = "" } +# links = [{ title = "Website", href = "" }] +# +# For a full reference of all the available options, you can have a look at +# https://gleam.run/writing-gleam/gleam-toml/. + +[dependencies] +gleam_stdlib = ">= 1.0.0 and < 2.0.0" +wisp = ">= 2.2.2 and < 3.0.0" +grom = { path = "../.." } +envoy = ">= 1.2.0 and < 2.0.0" +gleam_erlang = ">= 1.3.0 and < 2.0.0" +gleam_otp = ">= 1.2.0 and < 2.0.0" +mist = ">= 6.0.3 and < 7.0.0" +gleam_http = ">= 4.3.0 and < 5.0.0" +logging = ">= 1.5.0 and < 2.0.0" +gleam_json = ">= 3.1.0 and < 4.0.0" + +[dev_dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/examples/http_interactions/manifest.toml b/examples/http_interactions/manifest.toml new file mode 100644 index 0000000..fec03a5 --- /dev/null +++ b/examples/http_interactions/manifest.toml @@ -0,0 +1,49 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "bigi", version = "4.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "bigi", source = "hex", outer_checksum = "44FEDF1DF5C1F426A80B67C32B976D0828D66524E11BDC2DD22B78D37C883BA7" }, + { name = "directories", version = "1.2.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "D13090CFCDF6759B87217E8DDD73A75903A700148A82C1D33799F333E249BF9E" }, + { name = "envoy", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "9C6FBB6BFA02A52798BEEC5977A738CAD6E4A057F4B67FD0C8061AD2502C191A" }, + { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, + { name = "gleam_crypto", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "2DE9E4EF53CF6FEE049D4F765731F7178F7A11AEFAE00EEE63BF7536B354AD3F" }, + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, + { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, + { name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" }, + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, + { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, + { name = "gleam_stdlib", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "960090C2FB391784BB34267B099DC9315CC1B1F6013E7415BC763CEF1905D7D3" }, + { name = "gleam_time", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "533D8723774D61AD4998324F5DD1DABDCDBFABAFB9E87CB5D03C6955448FC97D" }, + { name = "gleeunit", version = "1.10.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "254B697FE72EEAD7BF82E941723918E421317813AC49923EE76A18C788C61E72" }, + { name = "glisten", version = "9.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "7795AA50830656F3A0316A6B26595F893C83272DA901B3405E31339CAA31A10B" }, + { name = "gramps", version = "6.0.1", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "D55636072DEE173F6586A5679D3C02EC7A0DE3F8646B78C351B72908FF223DF7" }, + { name = "grom", version = "5.1.5", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_httpc", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "kryptos", "multipart_form", "operating_system", "splitter", "status_code", "stratus"], source = "local", path = "../.." }, + { name = "houdini", version = "1.2.1", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "6F8AC2F12974567FB744BEA66AC93CEB76AAEA19AD28564623F76CDA9BC26A85" }, + { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, + { name = "kryptos", version = "1.4.0", build_tools = ["gleam"], requirements = ["bigi", "gleam_stdlib", "gleam_time"], otp_app = "kryptos", source = "hex", outer_checksum = "734DAB837962A4A45788F8E6350EF4A6DF20A1F54734736CD1FF3A03FFCE4C97" }, + { name = "logging", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "BC5F18CE5DD9686100229FE5409BDC3DD5C46D5A7DF2F804AD2D8F0DD6C5060E" }, + { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, + { name = "mist", version = "6.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "1B07F321D5FA0CB162D81496F2DE96AEB6EF8980F4F38230A4CC3F849497E020" }, + { name = "multipart_form", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_stdlib"], otp_app = "multipart_form", source = "hex", outer_checksum = "082C77A0C3BB1128FCD55491665E9B72BC943E849B67D02B08CFA6808AD8E47C" }, + { name = "operating_system", version = "1.0.1", build_tools = ["gleam"], requirements = [], otp_app = "operating_system", source = "hex", outer_checksum = "682D4D19496E607A6E6229AA51A38A1400ED5067C2C54D56843DBCB0C61FFA6D" }, + { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, + { name = "splitter", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "3DFD6B6C49E61EDAF6F7B27A42054A17CFF6CA2135FF553D0CB61C234D281DD0" }, + { name = "status_code", version = "1.1.0", build_tools = ["gleam"], requirements = [], otp_app = "status_code", source = "hex", outer_checksum = "F6EC765345A634602AD030EB1B4D34A425873CBC223CEC595EBF062073CD70BC" }, + { name = "stratus", version = "3.0.0", build_tools = ["gleam"], requirements = ["exception", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gramps", "logging"], otp_app = "stratus", source = "hex", outer_checksum = "21C93D657E4E7964E36EACDE83609459F259289DF403B5FEBD3C60612594BE24" }, + { name = "wisp", version = "2.2.2", build_tools = ["gleam"], requirements = ["directories", "exception", "filepath", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "houdini", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "5FF5F1E288C3437252ABB93D8F9CF42FF652CE7AD54480CFE736038DC09C4F22" }, +] + +[requirements] +envoy = { version = ">= 1.2.0 and < 2.0.0" } +gleam_erlang = { version = ">= 1.3.0 and < 2.0.0" } +gleam_http = { version = ">= 4.3.0 and < 5.0.0" } +gleam_json = { version = ">= 3.1.0 and < 4.0.0" } +gleam_otp = { version = ">= 1.2.0 and < 2.0.0" } +gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +grom = { path = "../.." } +logging = { version = ">= 1.5.0 and < 2.0.0" } +mist = { version = ">= 6.0.3 and < 7.0.0" } +wisp = { version = ">= 2.2.2 and < 3.0.0" } diff --git a/examples/http_interactions/src/http_interactions.gleam b/examples/http_interactions/src/http_interactions.gleam new file mode 100644 index 0000000..7639f14 --- /dev/null +++ b/examples/http_interactions/src/http_interactions.gleam @@ -0,0 +1,228 @@ +//// Hi there! +//// Did you read the README yet? +//// If not, now is a good time to do so. This code won't make a whole lot of sense without reading the README. + +import envoy +import gleam/erlang/process +import gleam/http/request.{Request} +import gleam/http/response +import gleam/option.{Some} +import gleam/otp/actor +import gleam/otp/factory_supervisor +import gleam/otp/static_supervisor +import grom +import grom/command +import grom/interaction.{type Interaction} +import logging +import mist +import wisp +import wisp/wisp_mist + +type RequestHandlerState { + RequestHandlerState( + client: grom.Client, + discord_public_key: String, + interaction_handler_name: process.Name( + factory_supervisor.Message( + Interaction, + process.Subject(InteractionHandlerMessage), + ), + ), + ) +} + +type InteractionHandlerState { + InteractionHandlerState(client: grom.Client) +} + +pub fn main() -> Nil { + // First, let's get our environment variables: + let assert Ok(token) = envoy.get("DISCORD_TOKEN") + + // We normally get our application's ID from the Gateway READY event. + // Since we're not connecting to the gateway, we have to hard-code it. + let assert Ok(application_id) = envoy.get("DISCORD_APPLICATION_ID") + + // The public key is specific to your application. + // It's used to verify the origin of HTTP requests is Discord, and that Discord intended to send that interaction to your app. + let assert Ok(public_key) = envoy.get("DISCORD_PUBLIC_KEY") + + // We're going to be using wisp, so let's configure its logger: + wisp.configure_logger() + + // Let's create the grom client: + let client = grom.Client(token:) + + // Let's create a test command. + let global_commands = [ + command.CreateGlobalSlash(command.new_create_global_slash_command( + named: "ping", + description: "Ping pong!", + )), + ] + + // We're asserting to make sure that command is 100% there when we start up our program. + let assert Ok(_commands) = + client + |> command.bulk_overwrite_global(of: application_id, new: global_commands) + + // ----- + // Let's create a factory supervisor, which will create workers which will handle our interactions. + // This makes sure that Discord's HTTP request doesn't timeout. + // + // Note: You still have 3 seconds to send `interaction.respond`, and either defer it or send a message. + // ----- + + // Let's create a name, which will be a way to contact our factory from our request handler. + let interaction_handler_name = process.new_name("interaction_handler_factory") + + // We're going to create a static supervisor later on, so let's use the `factory_supervisor.supervised` function. + let interaction_handler_factory = + // Using `factory_supervisor.worker_child` sets the worker's timeout at 5000ms by default. + // + // Look into using the `factory_supervisor.supervisor_child` if your interaction handler is also a supervisor. + factory_supervisor.worker_child(fn(interaction) { + start_interaction_handler(InteractionHandlerState(client), interaction) + }) + |> factory_supervisor.named(interaction_handler_name) + |> factory_supervisor.supervised + + // ----- + // We're going to be using webhooks as a way to handle interactions, so let's create an API endpoints, + // where Discord will be sending our interactions. + // ----- + + // From the wisp docs: + // + // Here we generate a secret key, but in a real application you would want to + // load this from somewhere so that it is not regenerated on every restart. + let secret_key_base = wisp.random_string(64) + + // We're going to create a static supervisor later on, so let's use the `mist.supervised` function. + let http_server = + wisp_mist.handler( + fn(request) { + handle_request( + request, + RequestHandlerState(client, public_key, interaction_handler_name), + ) + }, + secret_key_base, + ) + |> mist.new + |> mist.port(2137) + // This has to be a public internet facing API. + |> mist.bind("0.0.0.0") + |> mist.supervised + + let assert Ok(_supervisor) = + static_supervisor.new(static_supervisor.OneForOne) + |> static_supervisor.add(interaction_handler_factory) + |> static_supervisor.add(http_server) + |> static_supervisor.start + + process.sleep_forever() +} + +type InteractionHandlerMessage { + InteractionCreated(interaction: Interaction) +} + +fn start_interaction_handler( + state: InteractionHandlerState, + interaction: Interaction, +) -> Result( + actor.Started(process.Subject(InteractionHandlerMessage)), + actor.StartError, +) { + actor.new_with_initialiser(4000, fn(subject) { + let selector = process.new_selector() + + process.send(subject, InteractionCreated(interaction)) + + actor.initialised(state) + |> actor.returning(subject) + |> actor.selecting(selector) + |> Ok + }) + |> actor.on_message(handle_interaction_handler_message) + |> actor.start +} + +fn handle_interaction_handler_message( + state: InteractionHandlerState, + message: InteractionHandlerMessage, +) -> actor.Next(InteractionHandlerState, a) { + case message { + InteractionCreated(interaction) -> handle_interaction(state, interaction) + } +} + +fn handle_interaction( + state: InteractionHandlerState, + interaction: Interaction, +) -> actor.Next(InteractionHandlerState, a) { + let _result = + state.client + |> interaction.respond( + to: interaction, + using: interaction.RespondWithChannelMessageWithSource( + interaction.ResponseMessage( + ..interaction.new_response_message(), + content: Some("Pong!"), + ), + ), + ) + + actor.continue(state) +} + +fn handle_request( + request: wisp.Request, + state: RequestHandlerState, +) -> wisp.Response { + case wisp.path_segments(request) { + ["discord-interactions"] -> { + use body <- wisp.require_string_body(request) + + let request = Request(..request, body:) + + interaction.handle_http_interaction_request( + request, + state.discord_public_key, + fn(interaction) { handle_interaction_request(state, interaction) }, + ) + |> response.map(wisp.Text) + } + _ -> wisp.not_found() + } +} + +fn handle_interaction_request( + state: RequestHandlerState, + interaction: Result(Interaction, interaction.HttpError), +) -> Nil { + case interaction { + Ok(interaction) -> { + let factory = + factory_supervisor.get_by_name(state.interaction_handler_name) + + let start_result = + factory + |> factory_supervisor.start_child(interaction) + + case start_result { + Ok(_) -> logging.log(logging.Info, "Started interaction handler worker") + Error(_) -> + logging.log( + logging.Warning, + "Could not start interaction handler worker", + ) + } + } + Error(interaction.CouldNotParseInteraction(_)) -> + logging.log(logging.Warning, "Could not parse interaction") + Error(interaction.CouldNotValidateSecurityHeaders(_)) -> + logging.log(logging.Warning, "Could not validate security headers") + } +} diff --git a/examples/http_interactions/test/http_interactions_test.gleam b/examples/http_interactions/test/http_interactions_test.gleam new file mode 100644 index 0000000..fba3c88 --- /dev/null +++ b/examples/http_interactions/test/http_interactions_test.gleam @@ -0,0 +1,13 @@ +import gleeunit + +pub fn main() -> Nil { + gleeunit.main() +} + +// gleeunit test functions end in `_test` +pub fn hello_world_test() { + let name = "Joe" + let greeting = "Hello, " <> name <> "!" + + assert greeting == "Hello, Joe!" +} -- 2.51.2