api-surface #1
5 changed files with 142 additions and 65 deletions
feat(slash): typed %Interaction{} dispatch + struct-tolerant commands/0
commit
07ca39e5a7
|
|
@ -84,10 +84,10 @@ defmodule Dexcord.Dispatcher do
|
|||
# handler.
|
||||
#
|
||||
# If decode FELL BACK to the raw map (malformed interaction), the struct gate
|
||||
# can't fire; we degrade to the old integer `"type"` gate so a well-typed-enough
|
||||
# raw interaction still reaches the slash layer. The RAW map is passed to
|
||||
# `Slash.dispatch/2` in this task (Task 4 flips the typed branch to pass the
|
||||
# decoded struct).
|
||||
# can't fire; we degrade to the old integer `"type"` gate and hand the RAW map to
|
||||
# `Slash.dispatch/2`'s documented degraded head so a not-quite-decodable
|
||||
# interaction still reaches the slash layer. A cleanly decoded interaction routes
|
||||
# the typed `%Dexcord.Interaction{}` struct.
|
||||
defp maybe_route_slash(:INTERACTION_CREATE, decoded, raw, config) do
|
||||
slash_mod = Map.get(config, :slash)
|
||||
|
||||
|
|
@ -97,7 +97,7 @@ defmodule Dexcord.Dispatcher do
|
|||
|
||||
match?(%Dexcord.Interaction{}, decoded) and
|
||||
decoded.type in [:application_command, :message_component, :modal_submit] ->
|
||||
route_slash(raw, slash_mod)
|
||||
route_slash(decoded, slash_mod)
|
||||
|
||||
is_map(raw) and raw["type"] in [2, 3, 5] ->
|
||||
route_slash(raw, slash_mod)
|
||||
|
|
|
|||
|
|
@ -31,13 +31,13 @@ defmodule Dexcord.Slash do
|
|||
def handle_modal("feedback_form", itx), do: Dexcord.Slash.respond(itx, "thanks!")
|
||||
end
|
||||
|
||||
`dispatch/2` routes a raw `INTERACTION_CREATE` payload on its **top-level**
|
||||
`interaction["type"]`: type 2 (application command) → `handle_interaction/2`
|
||||
keyed on `interaction["data"]["name"]`; type 3 (message component) →
|
||||
`handle_component/2` keyed on `interaction["data"]["custom_id"]`; type 5 (modal
|
||||
submit) → `handle_modal/2` keyed on `interaction["data"]["custom_id"]`. The
|
||||
`dispatch/2` routes a decoded `%Dexcord.Interaction{}` on its `type` atom:
|
||||
`:application_command` → `handle_interaction/2` keyed on the data's `name`;
|
||||
`:message_component` → `handle_component/2` keyed on the data's `custom_id`;
|
||||
`:modal_submit` → `handle_modal/2` keyed on the data's `custom_id`. The
|
||||
`Dexcord.Dispatcher` calls it automatically for those types when a `slash:`
|
||||
module is configured (the raw event still reaches the handler).
|
||||
module is configured (the event still reaches the handler too). Every callback
|
||||
receives the full `%Dexcord.Interaction{}` as its second argument.
|
||||
|
||||
## Response helpers
|
||||
|
||||
|
|
@ -56,13 +56,22 @@ defmodule Dexcord.Slash do
|
|||
@callback commands() :: [map()]
|
||||
|
||||
@doc "Handles a routed application-command interaction (type 2) for command `name`."
|
||||
@callback handle_interaction(name :: String.t(), interaction :: map()) :: any()
|
||||
@callback handle_interaction(
|
||||
name :: String.t() | nil,
|
||||
interaction :: Dexcord.Interaction.t()
|
||||
) :: any()
|
||||
|
||||
@doc "Handles a routed message-component interaction (type 3) for `custom_id`."
|
||||
@callback handle_component(custom_id :: String.t() | nil, interaction :: map()) :: any()
|
||||
@callback handle_component(
|
||||
custom_id :: String.t() | nil,
|
||||
interaction :: Dexcord.Interaction.t()
|
||||
) :: any()
|
||||
|
||||
@doc "Handles a routed modal-submit interaction (type 5) for `custom_id`."
|
||||
@callback handle_modal(custom_id :: String.t() | nil, interaction :: map()) :: any()
|
||||
@callback handle_modal(
|
||||
custom_id :: String.t() | nil,
|
||||
interaction :: Dexcord.Interaction.t()
|
||||
) :: any()
|
||||
|
||||
# Only `commands/0` and `handle_interaction/2` are required; a module that never
|
||||
# uses components or modals need not define those callbacks (the injected
|
||||
|
|
@ -112,21 +121,42 @@ defmodule Dexcord.Slash do
|
|||
# --- routing ------------------------------------------------------------
|
||||
|
||||
@doc """
|
||||
Routes an `INTERACTION_CREATE` payload to `mod` on its top-level `"type"`.
|
||||
Routes a decoded `%Dexcord.Interaction{}` to `mod` on its `type` atom.
|
||||
|
||||
* type 2 (application command) → `mod.handle_interaction/2`, keyed on
|
||||
`interaction["data"]["name"]`
|
||||
* type 3 (message component) → `mod.handle_component/2`, keyed on
|
||||
`interaction["data"]["custom_id"]`
|
||||
* type 5 (modal submit) → `mod.handle_modal/2`, keyed on
|
||||
`interaction["data"]["custom_id"]`
|
||||
* `:application_command` → `mod.handle_interaction/2`, keyed on the data's `name`
|
||||
* `:message_component` → `mod.handle_component/2`, keyed on the data's `custom_id`
|
||||
* `:modal_submit` → `mod.handle_modal/2`, keyed on the data's `custom_id`
|
||||
|
||||
Any other (or missing) type is ignored - the `Dexcord.Dispatcher` only routes
|
||||
types 2/3/5 here, so this is defensive.
|
||||
Any other (or nil) type is ignored - the `Dexcord.Dispatcher` only routes those
|
||||
three types here, so this is defensive.
|
||||
|
||||
A malformed `INTERACTION_CREATE` that fails to decode into a struct is routed by
|
||||
the dispatcher's integer-type gate to the **degraded raw-map head** below, which
|
||||
reproduces the pre-typed routing on the top-level integer `"type"` so a
|
||||
not-quite-decodable interaction still reaches the handler with the raw map.
|
||||
"""
|
||||
@spec dispatch(map(), module()) :: any()
|
||||
def dispatch(interaction, mod) when is_map(interaction) and is_atom(mod) do
|
||||
case interaction["type"] do
|
||||
@spec dispatch(Dexcord.Interaction.t() | map(), module()) :: any()
|
||||
def dispatch(%Dexcord.Interaction{} = interaction, mod) when is_atom(mod) do
|
||||
case interaction.type do
|
||||
:application_command ->
|
||||
mod.handle_interaction(data_field(interaction, :name), interaction)
|
||||
|
||||
:message_component ->
|
||||
mod.handle_component(data_field(interaction, :custom_id), interaction)
|
||||
|
||||
:modal_submit ->
|
||||
mod.handle_modal(data_field(interaction, :custom_id), interaction)
|
||||
|
||||
_ ->
|
||||
:ignore
|
||||
end
|
||||
end
|
||||
|
||||
# Degraded raw-map head: a raw INTERACTION_CREATE the decoder could not turn into
|
||||
# a %Dexcord.Interaction{}. Routes exactly as the pre-typed dispatcher did, on the
|
||||
# top-level integer "type", handing the raw map to the callback.
|
||||
def dispatch(%{"type" => type} = interaction, mod) when is_atom(mod) do
|
||||
case type do
|
||||
2 -> mod.handle_interaction(get_in(interaction, ["data", "name"]), interaction)
|
||||
3 -> mod.handle_component(get_in(interaction, ["data", "custom_id"]), interaction)
|
||||
5 -> mod.handle_modal(get_in(interaction, ["data", "custom_id"]), interaction)
|
||||
|
|
@ -134,6 +164,12 @@ defmodule Dexcord.Slash do
|
|||
end
|
||||
end
|
||||
|
||||
# Reads a field from an interaction's typed `data` variant struct (or nil-data).
|
||||
# `data` is one of the ApplicationCommandData / MessageComponentData /
|
||||
# ModalSubmitData structs - all plain maps to `Map.get/2`; nil-data yields nil.
|
||||
defp data_field(%{data: %{} = data}, key), do: Map.get(data, key)
|
||||
defp data_field(_interaction, _key), do: nil
|
||||
|
||||
# --- response helpers ---------------------------------------------------
|
||||
|
||||
@doc """
|
||||
|
|
@ -142,15 +178,16 @@ defmodule Dexcord.Slash do
|
|||
`text_or_map` is either a binary (used as `content`) or a map supporting
|
||||
`content`, `embeds`, `components`, and `ephemeral: true`.
|
||||
"""
|
||||
@spec respond(map(), String.t() | map()) :: {:ok, map()} | {:ok, nil} | {:error, term()}
|
||||
@spec respond(Dexcord.Interaction.t(), String.t() | map()) ::
|
||||
{:ok, map()} | {:ok, nil} | {:error, term()}
|
||||
def respond(interaction, content) when is_binary(content) do
|
||||
respond(interaction, %{content: content})
|
||||
end
|
||||
|
||||
def respond(interaction, %{} = data) do
|
||||
def respond(%Dexcord.Interaction{} = interaction, %{} = data) do
|
||||
Dexcord.Api.create_interaction_response(
|
||||
interaction["id"],
|
||||
interaction["token"],
|
||||
interaction.id,
|
||||
interaction.token,
|
||||
%{"type" => 4, "data" => message_data(data)}
|
||||
)
|
||||
end
|
||||
|
|
@ -159,11 +196,11 @@ defmodule Dexcord.Slash do
|
|||
Sends a deferred response (type 5) - shows a loading state while you prepare a
|
||||
followup or edit the original response.
|
||||
"""
|
||||
@spec respond_later(map()) :: {:ok, map()} | {:ok, nil} | {:error, term()}
|
||||
def respond_later(interaction) do
|
||||
@spec respond_later(Dexcord.Interaction.t()) :: {:ok, map()} | {:ok, nil} | {:error, term()}
|
||||
def respond_later(%Dexcord.Interaction{} = interaction) do
|
||||
Dexcord.Api.create_interaction_response(
|
||||
interaction["id"],
|
||||
interaction["token"],
|
||||
interaction.id,
|
||||
interaction.token,
|
||||
%{"type" => 5}
|
||||
)
|
||||
end
|
||||
|
|
@ -173,15 +210,15 @@ defmodule Dexcord.Slash do
|
|||
|
||||
`text_or_map` is a binary (used as `content`) or a map as in `respond/2`.
|
||||
"""
|
||||
@spec followup(map(), String.t() | map()) :: {:ok, map()} | {:error, term()}
|
||||
@spec followup(Dexcord.Interaction.t(), String.t() | map()) :: {:ok, map()} | {:error, term()}
|
||||
def followup(interaction, content) when is_binary(content) do
|
||||
followup(interaction, %{content: content})
|
||||
end
|
||||
|
||||
def followup(interaction, %{} = data) do
|
||||
def followup(%Dexcord.Interaction{} = interaction, %{} = data) do
|
||||
Dexcord.Api.create_followup_message(
|
||||
interaction["application_id"],
|
||||
interaction["token"],
|
||||
interaction.application_id,
|
||||
interaction.token,
|
||||
message_data(data)
|
||||
)
|
||||
end
|
||||
|
|
@ -191,15 +228,16 @@ defmodule Dexcord.Slash do
|
|||
|
||||
`text_or_map` is a binary (used as `content`) or a map as in `respond/2`.
|
||||
"""
|
||||
@spec edit_response(map(), String.t() | map()) :: {:ok, map()} | {:error, term()}
|
||||
@spec edit_response(Dexcord.Interaction.t(), String.t() | map()) ::
|
||||
{:ok, map()} | {:error, term()}
|
||||
def edit_response(interaction, content) when is_binary(content) do
|
||||
edit_response(interaction, %{content: content})
|
||||
end
|
||||
|
||||
def edit_response(interaction, %{} = data) do
|
||||
def edit_response(%Dexcord.Interaction{} = interaction, %{} = data) do
|
||||
Dexcord.Api.edit_original_interaction_response(
|
||||
interaction["application_id"],
|
||||
interaction["token"],
|
||||
interaction.application_id,
|
||||
interaction.token,
|
||||
message_data(data)
|
||||
)
|
||||
end
|
||||
|
|
@ -213,7 +251,7 @@ defmodule Dexcord.Slash do
|
|||
base =
|
||||
Enum.reduce([:content, :embeds, :components], %{}, fn field, acc ->
|
||||
case fetch_any(data, field) do
|
||||
{:ok, value} -> Map.put(acc, Atom.to_string(field), value)
|
||||
{:ok, value} -> Map.put(acc, Atom.to_string(field), encode_values(value))
|
||||
:error -> acc
|
||||
end
|
||||
end)
|
||||
|
|
@ -224,6 +262,14 @@ defmodule Dexcord.Slash do
|
|||
end
|
||||
end
|
||||
|
||||
# Normalizes recognised field values for the wire: Dexcord structs (e.g.
|
||||
# `%Dexcord.Embed{}`) go through their own `to_map/1`; lists recurse; anything
|
||||
# else (a binary, a plain atom/string-keyed map) passes through unchanged and is
|
||||
# serialized directly by the JSON encoder downstream.
|
||||
defp encode_values(%_{} = struct), do: struct.__struct__.to_map(struct)
|
||||
defp encode_values(list) when is_list(list), do: Enum.map(list, &encode_values/1)
|
||||
defp encode_values(value), do: value
|
||||
|
||||
# Combines a caller-supplied integer `flags` with the ephemeral bit (64).
|
||||
# Returns nil when neither is present so the key is omitted entirely.
|
||||
defp flags(data) do
|
||||
|
|
|
|||
|
|
@ -99,7 +99,15 @@ defmodule Dexcord.Slash.Registrar do
|
|||
# A single end-to-end registration: resolve the app id, then overwrite commands.
|
||||
# Returns :ok | {:error, message} - never exits.
|
||||
defp try_register(config) do
|
||||
commands = config.slash.commands()
|
||||
# Command defs may be plain maps or Dexcord command-builder structs; normalize
|
||||
# struct defs to their wire maps before the (untouched) downstream register/3.
|
||||
# `[]` normalizes to `[]`, so the empty-list global guard below still fires.
|
||||
commands =
|
||||
config.slash.commands()
|
||||
|> Enum.map(fn
|
||||
%_{} = struct -> struct.__struct__.to_map(struct)
|
||||
map when is_map(map) -> map
|
||||
end)
|
||||
|
||||
with {:ok, app_id} <- resolve_app_id() do
|
||||
Dexcord.Config.put_application_id(app_id)
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ defmodule Dexcord.ErgonomicsIntegrationTest do
|
|||
itx = %{"id" => "i2", "type" => 2, "token" => "tok", "data" => %{"name" => "ping"}}
|
||||
FakeGateway.push_dispatch(fake, "INTERACTION_CREATE", itx, 20)
|
||||
|
||||
assert_receive {:slash, "ping", ^itx}, @timeout
|
||||
assert_receive {:slash, "ping", %Dexcord.Interaction{type: :application_command}}, @timeout
|
||||
|
||||
assert_receive {:raw, :INTERACTION_CREATE, %Dexcord.Interaction{type: :application_command}},
|
||||
@timeout
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ defmodule Dexcord.SlashTest do
|
|||
|
||||
alias Dexcord.Api.Ratelimit
|
||||
alias Dexcord.FakeRest
|
||||
alias Dexcord.Interaction
|
||||
alias Dexcord.Slash
|
||||
|
||||
@token "test.token.value"
|
||||
|
|
@ -50,25 +51,33 @@ defmodule Dexcord.SlashTest do
|
|||
end
|
||||
|
||||
test "dispatch/2 routes a type-2 interaction to handle_interaction/2 by name" do
|
||||
itx = %{"type" => 2, "data" => %{"name" => "ping"}, "id" => "1"}
|
||||
itx = Interaction.from_map(%{"type" => 2, "data" => %{"name" => "ping"}, "id" => "1"})
|
||||
assert Slash.dispatch(itx, Commands) == :ok
|
||||
assert_received {:handled, "ping", ^itx}
|
||||
assert_received {:handled, "ping", %Dexcord.Interaction{type: :application_command}}
|
||||
end
|
||||
|
||||
test "dispatch/2 routes a type-3 (component) interaction to handle_component/2 by custom_id" do
|
||||
itx = %{"type" => 3, "data" => %{"custom_id" => "refresh"}, "id" => "2"}
|
||||
itx = Interaction.from_map(%{"type" => 3, "data" => %{"custom_id" => "refresh"}, "id" => "2"})
|
||||
assert Slash.dispatch(itx, Commands) == :ok
|
||||
assert_received {:component, "refresh", ^itx}
|
||||
assert_received {:component, "refresh", %Dexcord.Interaction{type: :message_component}}
|
||||
end
|
||||
|
||||
test "dispatch/2 routes a type-5 (modal) interaction to handle_modal/2 by custom_id" do
|
||||
itx = %{"type" => 5, "data" => %{"custom_id" => "feedback"}, "id" => "3"}
|
||||
itx = Interaction.from_map(%{"type" => 5, "data" => %{"custom_id" => "feedback"}, "id" => "3"})
|
||||
assert Slash.dispatch(itx, Commands) == :ok
|
||||
assert_received {:modal, "feedback", ^itx}
|
||||
assert_received {:modal, "feedback", %Dexcord.Interaction{type: :modal_submit}}
|
||||
end
|
||||
|
||||
test "the raw-map degraded path still routes on the integer type" do
|
||||
# A malformed interaction the dispatcher could not decode into a struct is
|
||||
# routed here as a raw map; it must still reach the callback by integer type.
|
||||
raw = %{"type" => 2, "data" => %{"name" => "ping"}, "id" => "1"}
|
||||
assert Slash.dispatch(raw, Commands) == :ok
|
||||
assert_received {:handled, "ping", ^raw}
|
||||
end
|
||||
|
||||
test "the injected catch-all logs a warning for an unhandled command name" do
|
||||
itx = %{"type" => 2, "data" => %{"name" => "unknown"}}
|
||||
itx = Interaction.from_map(%{"type" => 2, "data" => %{"name" => "unknown"}})
|
||||
|
||||
log =
|
||||
capture_log(fn ->
|
||||
|
|
@ -80,8 +89,8 @@ defmodule Dexcord.SlashTest do
|
|||
end
|
||||
|
||||
test "the injected component/modal catch-alls log at debug, not warning" do
|
||||
component = %{"type" => 3, "data" => %{"custom_id" => "nope"}}
|
||||
modal = %{"type" => 5, "data" => %{"custom_id" => "nope"}}
|
||||
component = Interaction.from_map(%{"type" => 3, "data" => %{"custom_id" => "nope"}})
|
||||
modal = Interaction.from_map(%{"type" => 5, "data" => %{"custom_id" => "nope"}})
|
||||
|
||||
log =
|
||||
capture_log([level: :debug], fn ->
|
||||
|
|
@ -95,12 +104,12 @@ defmodule Dexcord.SlashTest do
|
|||
end
|
||||
|
||||
test "a module defining only handle_interaction/2 still routes components/modals via defaults" do
|
||||
itx2 = %{"type" => 2, "data" => %{"name" => "ping"}}
|
||||
itx2 = Interaction.from_map(%{"type" => 2, "data" => %{"name" => "ping"}})
|
||||
Slash.dispatch(itx2, LegacyCommands)
|
||||
assert_received {:legacy, ^itx2}
|
||||
assert_received {:legacy, %Dexcord.Interaction{type: :application_command}}
|
||||
|
||||
component = %{"type" => 3, "data" => %{"custom_id" => "x"}}
|
||||
modal = %{"type" => 5, "data" => %{"custom_id" => "y"}}
|
||||
component = Interaction.from_map(%{"type" => 3, "data" => %{"custom_id" => "x"}})
|
||||
modal = Interaction.from_map(%{"type" => 5, "data" => %{"custom_id" => "y"}})
|
||||
|
||||
capture_log([level: :debug], fn ->
|
||||
assert Slash.dispatch(component, LegacyCommands) == :ignore
|
||||
|
|
@ -125,13 +134,14 @@ defmodule Dexcord.SlashTest do
|
|||
|
||||
# Interaction/application ids must be numeric snowflakes: the typed endpoint
|
||||
# surface casts `:interaction_id` / `:application_id` path params via
|
||||
# `Dexcord.Snowflake.cast/1`. The interaction token is an opaque string.
|
||||
@itx %{
|
||||
"id" => "100",
|
||||
"token" => "int-token",
|
||||
"application_id" => "200",
|
||||
"data" => %{"name" => "ping"}
|
||||
}
|
||||
# `Dexcord.Snowflake.cast/1`. The interaction token is an opaque string. The
|
||||
# response helpers take a decoded `%Dexcord.Interaction{}`, so build one.
|
||||
@itx Interaction.from_map(%{
|
||||
"id" => "100",
|
||||
"token" => "int-token",
|
||||
"application_id" => "200",
|
||||
"data" => %{"name" => "ping"}
|
||||
})
|
||||
|
||||
test "respond/2 with a string sends a type-4 content response to the callback route" do
|
||||
FakeRest.stub(:post, "/interactions/100/int-token/callback", FakeRest.resp(204))
|
||||
|
|
@ -158,6 +168,19 @@ defmodule Dexcord.SlashTest do
|
|||
assert body["data"]["embeds"] == [%{"title" => "x"}]
|
||||
end
|
||||
|
||||
test "respond/2 encodes struct data values (e.g. an %Embed{}) to wire maps" do
|
||||
FakeRest.stub(:post, "/interactions/100/int-token/callback", FakeRest.resp(204))
|
||||
|
||||
assert {:ok, nil} = Slash.respond(@itx, %{embeds: [%Dexcord.Embed{title: "t"}]})
|
||||
|
||||
assert_receive {:rest_hit, info}
|
||||
body = JSON.decode!(info.body)
|
||||
# The %Embed{} struct is run through its own to_map/1: string-keyed wire map,
|
||||
# title preserved, empty-list defaults (e.g. fields) retained as the encoder emits.
|
||||
assert [%{"title" => "t"} = embed] = body["data"]["embeds"]
|
||||
assert embed["fields"] == []
|
||||
end
|
||||
|
||||
test "respond/2 accepts string-keyed data maps too" do
|
||||
FakeRest.stub(:post, "/interactions/100/int-token/callback", FakeRest.resp(204))
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue