api-surface #1

Merged
luna merged 51 commits from api-surface into mistress 2026-07-05 14:43:13 +00:00
5 changed files with 142 additions and 65 deletions
Showing only changes of commit 07ca39e5a7 - Show all commits

feat(slash): typed %Interaction{} dispatch + struct-tolerant commands/0

Luna 2026-07-05 07:35:28 -03:00

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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))