diff --git a/lib/nosedrum/component_handler.ex b/lib/nosedrum/component_handler.ex new file mode 100644 index 0000000..9428e85 --- /dev/null +++ b/lib/nosedrum/component_handler.ex @@ -0,0 +1,88 @@ +defmodule Nosedrum.ComponentHandler do + @moduledoc """ + Handles incoming interactions triggered by interacting with a component. + + ## Register components + Before the component handler can handle incoming interactions, you must + register some [custom ids](https://discord.com/developers/docs/interactions/message-components#custom-id) + with the `c:Nosedrum.ComponentHandler.register_components/3` callback. The arguments for the callback + are one or more custom ids, a module or a pid that will handle the interaction + and additional data, that will be passed on to the module or pid that handles + the interaction. The additional data can be any arbitrary term. + + ### Module handlers + The registered module should implement the `Nosedrum.ComponentInteraction` + behaviour. The component handler will call the `c:Nosedrum.ComponentInteraction.message_component_interaction/2` + callback of the module when a matching interaction is found. + + ### Process handlers + Once a pid is registered, it will receive a message of the type `t:message_component_interaction/0` + every time a matching interaction is found. + + ## Handle incomming interactions + The `c:Nosedrum.ComponentHandler.handle_component_interaction/1` callback will look up the correct module or + pid and relay the interaction and additional data. The recommended place to + handle these interactions would be when handling the `:INTERACTION_CREATE` + event in the consumer. The following example uses the + `Nosedrum.ComponentHandler.ETS` implementation: + + ```elixir + # The ready event would be a possible place where you could register static + # component handlers. + def handle_event({:READY, _, _}) do + Nosedrum.ComponentHandler.ETS.register_component(["next_button", "previous_button"], + MyApp.ButtonHandler, nil) + end + + # Handle the interaction create in your consumer module. + def handle_event({:INTERACTION_CREATE, interaction, _}) do + case interaction.type do + 1 -> Nostrum.Api.create_interaction_response(interaction, %{type: 1}) + 2 -> Nosedrum.Storage.Dispatcher.handle_interaction(interaction) + x when x in 3..5 -> Nosedrum.ComponentHandler.ETS.handle_component_interaction(interaction) + end + end + ``` + + ```elixir + # Start the Component handler in your Application. Ideally before your Consumer. + defmodule MyApp.Application do + # ... + def start(type, args) do + children = [ + # ... + Nosedrum.ComponentHandler.ETS + ] + + options = [strategy: :rest_for_one, name: MyApp.Supervisor] + Supervisor.start_link(children, options) + end + end + ``` + + ```elixir + # A simple module for handling component interactions + defmodule MyApp.ButtonHandler do + @behaviour Nosedrum.ComponentInteraction + + def message_component_interaction(interaction, _) do + case interaction.data.custom_id do + "next_button" -> [content: "The next button was clicked"] + "prev_button" -> [content: "The previous button was clicked"] + end + end + end + ``` + """ + + @type custom_ids :: + Nostrum.Struct.Component.custom_id() | [Nostrum.Struct.Component.custom_id()] + @type component_handler :: module() | pid() + @type additional_data :: term() + @type message_component_interaction :: + {:message_component_interaction, Nostrum.Struct.Interaction.t(), additional_data()} + + @callback register_components(custom_ids, component_handler, additional_data()) :: :ok + @callback handle_component_interaction(Nostrum.Struct.Interaction.t()) :: + :ok | {:error, Nostrum.Error.ApiError.t() | :not_found} +end diff --git a/lib/nosedrum/component_handler/ets.ex b/lib/nosedrum/component_handler/ets.ex new file mode 100644 index 0000000..d344161 --- /dev/null +++ b/lib/nosedrum/component_handler/ets.ex @@ -0,0 +1,100 @@ +defmodule Nosedrum.ComponentHandler.ETS do + @moduledoc """ + ETS-based implementation of a `Nosedrum.ComponentHandler`. + + When a pid is registered as a handler for components it will be monitored + and automatically removed once the process exits. Registered module handlers + on the other hand are never cleared. You can access the named ETS-table manually + to remove any keys if necessary. + + ## Options + * :name - name used for registering the process under and also the name of the ETS-table. + Must be a atom, because it will be passed to `:ets.new/2` as name. Defaults to `Nosedrum.ComponentHandler.ETS` + > ### Note {: .info} + > When using a different name than the default you must pass it as first argument of + > the callbacks. + > + > ```elixir + > # Assuming name was set to MyApp.ComponentHandler + > Nosedrum.Storage.ETS.register_components(MyApp.ComponentHandler, + > ["a", "b", "c"], MyApp.ComponentHandlers.ABCHandler) + > ``` + + """ + + @doc false + use GenServer + + @behaviour Nosedrum.ComponentHandler + + alias Nosedrum.Storage + + @impl Nosedrum.ComponentHandler + def register_components(server \\ __MODULE__, component_ids, module, additional_data) do + GenServer.call(server, {:register_components, component_ids, module, additional_data}) + end + + @impl Nosedrum.ComponentHandler + def handle_component_interaction( + server \\ __MODULE__, + %Nostrum.Struct.Interaction{} = interaction + ) do + component_id = interaction.data.custom_id + + case :ets.match(server, {component_id, :"$1", :"$2"}) do + [[pid, additional_data]] when is_pid(pid) -> + send(pid, {:message_component_interaction, interaction, additional_data}) + :ok + + [[module, additional_data]] when is_atom(module) -> + with response <- module.message_component_interaction(interaction, additional_data), + {:ok} <- Storage.respond(interaction, response), + {_defer_type, callback_tuple} <- Keyword.get(response, :type) do + Storage.followup(interaction, callback_tuple) + end + + [] -> + {:error, :not_found} + end + end + + @doc false + def start_link(opts) do + name = Keyword.get(opts, :name, __MODULE__) + GenServer.start_link(__MODULE__, opts, name: name) + end + + @impl GenServer + def init(opts) do + name = Keyword.get(opts, :name, __MODULE__) + table = :ets.new(name, [:named_table, :public, :ordered_set, read_concurrency: true]) + {:ok, table} + end + + @impl GenServer + def handle_call( + {:register_components, component_ids, component_handler, additional_data}, + _from, + table + ) do + if is_pid(component_handler) do + # Get notified then a stateful component handler exists + Process.monitor(component_handler) + end + + entries = + component_ids + |> List.wrap() + |> Enum.map(&{&1, component_handler, additional_data}) + + :ets.insert(table, entries) + {:reply, :ok, table} + end + + @impl GenServer + def handle_info({:DOWN, _, :process, pid, _}, table) do + # Remove stateful component handlers when the process exits + :ets.match_delete(table, {:_, pid}) + {:noreply, table} + end +end diff --git a/lib/nosedrum/component_interaction.ex b/lib/nosedrum/component_interaction.ex new file mode 100644 index 0000000..31ee941 --- /dev/null +++ b/lib/nosedrum/component_interaction.ex @@ -0,0 +1,20 @@ +defmodule Nosedrum.ComponentInteraction do + @moduledoc """ + Behaviour for processing an interaction triggered from a message component. + + Modules implementing this behaviour can be registered to a command handler + via `c:Nosedrum.ComponentHandler.register_components/3`. See the module + documentation for `Nosedrum.ComponentHandler` for more information. + """ + + @doc """ + Handle message component interactions. + + Behaves the same way as the `Nosedrum.ApplicationCommand.commmand/1` callback. + """ + @callback message_component_interaction( + interaction :: Nostrum.Struct.Interaction.t(), + Nosedrum.ComponentHandler.additional_data() + ) :: + Nosedrum.ApplicationCommand.response() +end diff --git a/mix.exs b/mix.exs index 3bf044e..a93b1bc 100644 --- a/mix.exs +++ b/mix.exs @@ -41,6 +41,10 @@ defmodule Nosedrum.MixProject do Nosedrum.Storage, Nosedrum.Storage.Dispatcher ], + "Component Handler": [ + Nosedrum.ComponentHandler, + Nosedrum.ComponentInteraction + ], Functionality: [Nosedrum.Converters, Nosedrum.Helpers, Nosedrum.TextCommand.Predicates], Behaviours: [ Nosedrum.TextCommand, @@ -52,7 +56,8 @@ defmodule Nosedrum.MixProject do Nosedrum.TextCommand.Invoker.Split, Nosedrum.MessageCache.Agent, Nosedrum.MessageCache.ETS, - Nosedrum.TextCommand.Storage.ETS + Nosedrum.TextCommand.Storage.ETS, + Nosedrum.ComponentHandler.ETS ] ] ] diff --git a/mix.lock b/mix.lock index b49520c..29c8797 100644 --- a/mix.lock +++ b/mix.lock @@ -6,7 +6,9 @@ "cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"}, "credo": {:hex, :credo, "1.7.5", "643213503b1c766ec0496d828c90c424471ea54da77c8a168c725686377b9545", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "f799e9b5cd1891577d8c773d245668aa74a2fcd15eb277f51a0131690ebfb3fd"}, "curve25519": {:hex, :curve25519, "1.0.5", "f801179424e4012049fcfcfcda74ac04f65d0ffceeb80e7ef1d3352deb09f5bb", [:mix], [], "hexpm", "0fba3ad55bf1154d4d5fc3ae5fb91b912b77b13f0def6ccb3a5d58168ff4192d"}, + "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, + "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, "ed25519": {:hex, :ed25519, "1.4.1", "479fb83c3e31987c9cad780e6aeb8f2015fb5a482618cdf2a825c9aff809afc4", [:mix], [], "hexpm", "0dacb84f3faa3d8148e81019ca35f9d8dcee13232c32c9db5c2fb8ff48c80ec7"}, "equivalex": {:hex, :equivalex, "1.0.3", "170d9a82ae066e0020dfe1cf7811381669565922eb3359f6c91d7e9a1124ff74", [:mix], [], "hexpm", "46fa311adb855117d36e461b9c0ad2598f72110ad17ad73d7533c78020e045fc"}, @@ -14,15 +16,23 @@ "ex_doc": {:hex, :ex_doc, "0.32.1", "21e40f939515373bcdc9cffe65f3b3543f05015ac6c3d01d991874129d173420", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.1", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "5142c9db521f106d61ff33250f779807ed2a88620e472ac95dc7d59c380113da"}, "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, "forecastle": {:hex, :forecastle, "0.1.2", "f8dab08962c7a33010ebd39182513129f17b8814aa16fa453ddd536040882daf", [:mix], [], "hexpm", "8efaeb2e7d0fa24c605605e42562e2dbb0ffd11dc1dd99ef77d78884536ce501"}, + "gen_stage": {:hex, :gen_stage, "1.2.1", "19d8b5e9a5996d813b8245338a28246307fd8b9c99d1237de199d21efc4c76a1", [:mix], [], "hexpm", "83e8be657fa05b992ffa6ac1e3af6d57aa50aace8f691fcf696ff02f8335b001"}, "gun": {:hex, :gun, "2.1.0", "b4e4cbbf3026d21981c447e9e7ca856766046eff693720ba43114d7f5de36e87", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "52fc7fc246bfc3b00e01aea1c2854c70a366348574ab50c57dfe796d24a0101d"}, + "hackney": {:hex, :hackney, "1.17.0", "717ea195fd2f898d9fe9f1ce0afcc2621a41ecfe137fae57e7fe6e9484b9aa99", [:rebar3], [{:certifi, "~>2.5", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "64c22225f1ea8855f584720c0e5b3cd14095703af1c9fbc845ba042811dc671c"}, + "httpoison": {:hex, :httpoison, "1.8.0", "6b85dea15820b7804ef607ff78406ab449dd78bed923a49c7160e1886e987a3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "28089eaa98cf90c66265b6b5ad87c59a3729bea2e74e9d08f9b51eb9729b3c3a"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, "kcl": {:hex, :kcl, "1.4.2", "8b73a55a14899dc172fcb05a13a754ac171c8165c14f65043382d567922f44ab", [:mix], [{:curve25519, ">= 1.0.4", [hex: :curve25519, repo: "hexpm", optional: false]}, {:ed25519, "~> 1.3", [hex: :ed25519, repo: "hexpm", optional: false]}, {:poly1305, "~> 1.0", [hex: :poly1305, repo: "hexpm", optional: false]}, {:salsa20, "~> 1.0", [hex: :salsa20, repo: "hexpm", optional: false]}], "hexpm", "9f083dd3844d902df6834b258564a82b21a15eb9f6acdc98e8df0c10feeabf05"}, "makeup": {:hex, :makeup, "1.1.1", "fa0bc768698053b2b3869fa8a62616501ff9d11a562f3ce39580d60860c3a55e", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5dc62fbdd0de44de194898b6710692490be74baa02d9d108bc29f007783b0b48"}, "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, "makeup_erlang": {:hex, :makeup_erlang, "0.1.5", "e0ff5a7c708dda34311f7522a8758e23bfcd7d8d8068dc312b5eb41c6fd76eba", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "94d2e986428585a21516d7d7149781480013c56e30c6a233534bedf38867a59a"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, + "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, "nostrum": {:hex, :nostrum, "0.9.0", "ab062cec67d9fc207a1f0483683f10f5ff7985e5f5cf7bba6d073ef1fab41d41", [:mix], [{:castle, "~> 0.3.0", [hex: :castle, repo: "hexpm", optional: false]}, {:certifi, "~> 2.13", [hex: :certifi, repo: "hexpm", optional: false]}, {:gun, "~> 2.0", [hex: :gun, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:kcl, "~> 1.4", [hex: :kcl, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm", "8c43c6c2e213f0b43d1628c407a1e83f4baa1edcd9efe03c34d5c9ab4419846d"}, + "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, + "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm", "fec8660eb7733ee4117b85f55799fd3833eb769a6df71ccf8903e8dc5447cfce"}, "poly1305": {:hex, :poly1305, "1.0.4", "7cdc8961a0a6e00a764835918cdb8ade868044026df8ef5d718708ea6cc06611", [:mix], [{:chacha20, "~> 1.0", [hex: :chacha20, repo: "hexpm", optional: false]}, {:equivalex, "~> 1.0", [hex: :equivalex, repo: "hexpm", optional: false]}], "hexpm", "e14e684661a5195e149b3139db4a1693579d4659d65bba115a307529c47dbc3b"}, "salsa20": {:hex, :salsa20, "1.0.4", "404cbea1fa8e68a41bcc834c0a2571ac175580fec01cc38cc70c0fb9ffc87e9b", [:mix], [], "hexpm", "745ddcd8cfa563ddb0fd61e7ce48d5146279a2cf7834e1da8441b369fdc58ac6"}, }