api-surface #1

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

feat(dispatcher): typed handler delivery with raw fallback; Prefix on %Message{}

Luna 2026-07-05 07:30:04 -03:00

View file

@ -46,10 +46,10 @@ defmodule Dexcord.Dispatcher do
Dexcord.Cache.handle_dispatch(name, decoded, data, config)
maybe_request_members(name, data, config)
maybe_route_slash(name, data, config)
maybe_route_slash(name, decoded, data, config)
Task.Supervisor.start_child(@task_supervisor, fn ->
config.handler.handle_event({name, data})
config.handler.handle_event({name, decoded})
end)
{:noreply, state}
@ -76,22 +76,44 @@ defmodule Dexcord.Dispatcher do
defp maybe_request_members(_name, _data, _config), do: :ok
# When a `slash:` module is configured, auto-route INTERACTION_CREATEs to it in
# a separate Task, IN ADDITION to the raw handler (which always receives the
# event). Only the interaction top-level `"type"`s handled by the slash layer are
# routed: 2 (application command), 3 (message component), 5 (modal submit).
# Type 1 (PING) never arrives over the gateway and type 4 (autocomplete) is left
# to the raw handler.
defp maybe_route_slash(:INTERACTION_CREATE, interaction, config) when is_map(interaction) do
# a separate Task, IN ADDITION to the handler (which always receives the event).
# Routing gates on the DECODED `%Dexcord.Interaction{}`: only the three top-level
# interaction types handled by the slash layer are routed - `:application_command`
# (2), `:message_component` (3), `:modal_submit` (5). Type 1 (PING) never arrives
# over the gateway and type 4 (`:application_command_autocomplete`) is left to the
# 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).
defp maybe_route_slash(:INTERACTION_CREATE, decoded, raw, config) do
slash_mod = Map.get(config, :slash)
if slash_mod && interaction["type"] in [2, 3, 5] do
Task.Supervisor.start_child(@task_supervisor, fn ->
Dexcord.Slash.dispatch(interaction, slash_mod)
end)
cond do
is_nil(slash_mod) ->
:ok
match?(%Dexcord.Interaction{}, decoded) and
decoded.type in [:application_command, :message_component, :modal_submit] ->
route_slash(raw, slash_mod)
is_map(raw) and raw["type"] in [2, 3, 5] ->
route_slash(raw, slash_mod)
true ->
:ok
end
end
defp maybe_route_slash(_name, _decoded, _raw, _config), do: :ok
defp route_slash(interaction, slash_mod) do
Task.Supervisor.start_child(@task_supervisor, fn ->
Dexcord.Slash.dispatch(interaction, slash_mod)
end)
:ok
end
defp maybe_route_slash(_name, _data, _config), do: :ok
end

View file

@ -7,18 +7,28 @@ defmodule Dexcord.Handler do
injects a catch-all clause (via `@before_compile`) so unmatched events are
silently ignored - the user only writes the clauses they want.
The argument is a `{name, data}` tuple where `name` is a dispatch event atom
(e.g. `:MESSAGE_CREATE`) or `{:unknown_event, name}` for undocumented events,
and `data` is the raw string-keyed payload map.
The argument is a `{name, payload}` tuple where `name` is a dispatch event atom
(e.g. `:MESSAGE_CREATE`) or `{:unknown_event, name}` for undocumented events. The
`payload` is decoded exactly once, in the dispatcher, and its shape depends on the
event:
* **full-object events** (e.g. `:MESSAGE_CREATE`, `:GUILD_CREATE`) arrive as the
corresponding model struct - `%Dexcord.Message{}`, `%Dexcord.Guild{}`, etc.
* **envelope events** (e.g. `:GUILD_MEMBERS_CHUNK`, `:MESSAGE_REACTION_ADD`)
arrive as a `Dexcord.Events.*` struct.
* **passthrough, unknown, and decode-failure** payloads arrive as the raw
string-keyed map. A malformed payload that fails to decode logs a warning and
falls back to this raw form so dispatch never stalls.
defmodule MyBot.Handler do
use Dexcord.Handler
def handle_event({:MESSAGE_CREATE, msg}), do: IO.inspect(msg["content"])
def handle_event({:MESSAGE_CREATE, %Dexcord.Message{} = msg}),
do: IO.inspect(msg.content)
end
"""
@callback handle_event({atom() | {:unknown_event, String.t()}, map()}) :: any()
@callback handle_event({atom() | {:unknown_event, String.t()}, term()}) :: any()
defmacro __using__(_opts) do
quote do

View file

@ -4,7 +4,7 @@ defmodule Dexcord.Prefix do
`parse/2` is a pure function that recognises a prefixed command and splits it
into a command word plus its argument string / argument list. `dispatch/2`
wires a raw `MESSAGE_CREATE` payload through `parse/2` into a
wires a decoded `%Dexcord.Message{}` (`MESSAGE_CREATE`) through `parse/2` into a
`Dexcord.Prefix.Router` module, skipping messages authored by bots (including
the bot itself). A router `use`s `Dexcord.Prefix.Router` and defines
`handle_command/3` clauses for the commands it cares about; an `:ignore`
@ -14,11 +14,11 @@ defmodule Dexcord.Prefix do
use Dexcord.Prefix.Router
def handle_command("ping", _args, msg),
do: Dexcord.Api.create_message(msg["channel_id"], "pong")
do: Dexcord.Api.create_message(msg.channel_id, "pong")
end
# in the handler:
def handle_event({:MESSAGE_CREATE, msg}),
def handle_event({:MESSAGE_CREATE, %Dexcord.Message{} = msg}),
do: Dexcord.Prefix.dispatch(msg, prefix: "!", to: MyBot.Commands)
"""
@ -77,7 +77,8 @@ defmodule Dexcord.Prefix do
end
@doc """
Routes a raw `MESSAGE_CREATE` payload to a `Dexcord.Prefix.Router`.
Routes a decoded `%Dexcord.Message{}` (`MESSAGE_CREATE`) to a
`Dexcord.Prefix.Router`.
Options:
@ -93,29 +94,30 @@ defmodule Dexcord.Prefix do
On a match the router's `handle_command(command, args, msg)` is called and its
result returned; a non-match (or a skipped bot message) returns `:ignore`.
"""
@spec dispatch(map(), keyword()) :: any()
def dispatch(msg, opts) when is_map(msg) do
@spec dispatch(Dexcord.Message.t(), keyword()) :: any()
def dispatch(%Dexcord.Message{} = msg, opts) do
prefix = Keyword.fetch!(opts, :prefix)
router = Keyword.fetch!(opts, :to)
if bot_author?(msg) do
:ignore
else
case parse(msg["content"] || "", prefix) do
case parse(msg.content || "", prefix) do
{:ok, command, _arg_string, args} -> router.handle_command(command, args, msg)
:nomatch -> :ignore
end
end
end
defp bot_author?(msg) do
author = msg["author"] || %{}
author["bot"] == true or own_message?(author)
defp bot_author?(%Dexcord.Message{author: nil}), do: true
defp bot_author?(%Dexcord.Message{author: author}) do
author.bot == true or own_message?(author)
end
defp own_message?(author) do
defp own_message?(%Dexcord.User{id: id}) do
case Dexcord.Cache.me() do
{:ok, %Dexcord.User{id: id}} -> Dexcord.Snowflake.cast(author["id"]) == {:ok, id}
{:ok, %Dexcord.User{id: me_id}} -> id == me_id
_ -> false
end
end
@ -128,10 +130,14 @@ defmodule Dexcord.Prefix.Router do
`use Dexcord.Prefix.Router` declares the behaviour and injects an `:ignore`
catch-all `handle_command/3`, so a router only writes the command clauses it
wants. `handle_command/3` receives the command word, the whitespace-split
argument list, and the raw `MESSAGE_CREATE` map.
argument list, and the decoded `%Dexcord.Message{}`.
"""
@callback handle_command(command :: String.t(), args :: [String.t()], msg :: map()) :: any()
@callback handle_command(
command :: String.t(),
args :: [String.t()],
msg :: Dexcord.Message.t()
) :: any()
defmacro __using__(_opts) do
quote do

View file

@ -102,7 +102,8 @@ defmodule Dexcord.ErgonomicsIntegrationTest do
user_msg = %{"content" => "!ping a b", "author" => %{"id" => "u1", "bot" => false}}
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", user_msg, 10)
assert_receive {:command, "ping", ["a", "b"], ^user_msg}, @timeout
assert_receive {:command, "ping", ["a", "b"], %Dexcord.Message{content: "!ping a b"}},
@timeout
bot_msg = %{"content" => "!ping", "author" => %{"id" => "b1", "bot" => true}}
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", bot_msg, 11)
@ -116,7 +117,9 @@ defmodule Dexcord.ErgonomicsIntegrationTest do
FakeGateway.push_dispatch(fake, "INTERACTION_CREATE", itx, 20)
assert_receive {:slash, "ping", ^itx}, @timeout
assert_receive {:raw, :INTERACTION_CREATE, ^itx}, @timeout
assert_receive {:raw, :INTERACTION_CREATE, %Dexcord.Interaction{type: :application_command}},
@timeout
end
test "an autocomplete interaction (type 4) reaches the raw handler but is not auto-routed",
@ -124,7 +127,10 @@ defmodule Dexcord.ErgonomicsIntegrationTest do
itx = %{"id" => "i4", "type" => 4, "data" => %{"name" => "ping"}}
FakeGateway.push_dispatch(fake, "INTERACTION_CREATE", itx, 21)
assert_receive {:raw, :INTERACTION_CREATE, ^itx}, @timeout
assert_receive {:raw, :INTERACTION_CREATE,
%Dexcord.Interaction{type: :application_command_autocomplete}},
@timeout
refute_receive {:slash, _, _}, 300
end

View file

@ -9,6 +9,8 @@ defmodule Dexcord.GatewayIntegrationTest do
"""
use ExUnit.Case, async: false
import ExUnit.CaptureLog
alias Dexcord.FakeGateway
alias Dexcord.Gateway
alias Dexcord.Intents
@ -96,12 +98,50 @@ defmodule Dexcord.GatewayIntegrationTest do
# A dispatch reaches the user handler...
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", %{"content" => "hi"}, 7)
assert_receive {:handler_event, {:MESSAGE_CREATE, %{"content" => "hi"}}}, @event_timeout
assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "hi"}}},
@event_timeout
# ...and the next heartbeat carries the latest seq (7).
assert_receive {:fake_gw, :frame, _c, %{"op" => 1, "d" => 7}}, @event_timeout
end
# --- AC2.18 / AC2.19: typed delivery, decode-failure fallback, unknown events ---
# A payload the decoder cannot turn into a struct must not stall dispatch: it
# logs, falls back to the RAW payload, and the very next well-formed event still
# arrives fully typed. Also proves the unknown-event contract: an undocumented
# event name arrives as `{{:unknown_event, name}, raw_map}`.
test "AC2.19: a malformed payload logs, delivers raw, and dispatch keeps flowing typed" do
fake = start_fake(hello_interval: 80)
start_bot(fake)
assert_frame(2)
assert_receive {:handler_event, {:READY, _}}, @event_timeout
# A MESSAGE_CREATE whose `d` is a bare string can't decode to a %Message{}: the
# decoder returns nil, Events.decode logs a warning and falls back to the raw
# payload. The handler receives that raw string unchanged.
log =
capture_log(fn ->
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", "garbage", 7)
assert_receive {:handler_event, {:MESSAGE_CREATE, "garbage"}}, @event_timeout
end)
assert log =~ "MESSAGE_CREATE"
# Dispatch survived the bad payload: a following well-formed event decodes typed.
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", %{"content" => "recovered"}, 8)
assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "recovered"}}},
@event_timeout
# AC2.18 unknown half: an undocumented event arrives as {{:unknown_event, name}, raw}.
FakeGateway.push_dispatch(fake, "TOTALLY_NEW_EVENT", %{"x" => 1}, 9)
assert_receive {:handler_event, {{:unknown_event, "TOTALLY_NEW_EVENT"}, %{"x" => 1}}},
@event_timeout
end
# --- REVIEW FIX 1: heartbeat timer must re-arm while gap-waiting in :hello_wait -
# Verifies review finding #1. Deterministic and green in isolation
@ -218,7 +258,8 @@ defmodule Dexcord.GatewayIntegrationTest do
# The statem survived: a following real dispatch still reaches the user handler.
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", %{"content" => "alive"}, 6)
assert_receive {:handler_event, {:MESSAGE_CREATE, %{"content" => "alive"}}}, @event_timeout
assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "alive"}}},
@event_timeout
# And it never crashed/reconnected: heartbeats keep flowing on the same connection.
assert_receive {:fake_gw, :frame, _c, %{"op" => 1}}, @event_timeout
@ -249,7 +290,9 @@ defmodule Dexcord.GatewayIntegrationTest do
# Advance the live session's seq.
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", %{"content" => "x"}, 42)
assert_receive {:handler_event, {:MESSAGE_CREATE, %{"content" => "x"}}}, @event_timeout
assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "x"}}},
@event_timeout
wait_until(fn -> Session.last_seq() == 42 end)
# Force a resume; the server rejects it with op 9 d:false, clearing the session.
@ -259,7 +302,8 @@ defmodule Dexcord.GatewayIntegrationTest do
# Trickle a dispatch from the abandoned session during the reidentify wait.
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", %{"content" => "noise"}, 9_999)
assert_receive {:handler_event, {:MESSAGE_CREATE, %{"content" => "noise"}}}, @event_timeout
assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "noise"}}},
@event_timeout
# It was delivered, but its foreign seq must not have repopulated last_seq.
assert Session.last_seq() == nil, "abandoned-session frame repopulated last_seq"
@ -503,15 +547,21 @@ defmodule Dexcord.GatewayIntegrationTest do
assert_frame(2)
assert_receive {:handler_event, {:READY, _}}, @event_timeout
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", %{"content" => "seed"}, 5)
assert_receive {:handler_event, {:MESSAGE_CREATE, %{"content" => "seed"}}}, @event_timeout
assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "seed"}}},
@event_timeout
FakeGateway.push_op7(fake)
{_rc, 6, _} = assert_reconnect_frame()
# Replays arrive in order, before RESUMED.
assert_receive {:handler_event, {:MESSAGE_CREATE, %{"content" => "a"}}}, @event_timeout
assert_receive {:handler_event, {:MESSAGE_CREATE, %{"content" => "b"}}}, @event_timeout
assert_receive {:handler_event, {:MESSAGE_CREATE, %{"content" => "c"}}}, @event_timeout
assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "a"}}},
@event_timeout
assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "b"}}},
@event_timeout
assert_receive {:handler_event, {:MESSAGE_CREATE, %Dexcord.Message{content: "c"}}},
@event_timeout
assert_receive {:handler_event, {:RESUMED, _}}, @event_timeout
# Seq advanced to the last replayed dispatch.

View file

@ -76,30 +76,40 @@ defmodule Dexcord.PrefixTest do
test "skips the bot's own messages via the cached self id, even without a bot flag" do
raw = %{"user" => %{"id" => "123"}}
Dexcord.Cache.handle_dispatch(:READY, Dexcord.Events.decode(:READY, raw), raw, %{})
msg = %{"content" => "!ping", "author" => %{"id" => "123"}}
msg = Dexcord.Message.from_map(%{"content" => "!ping", "author" => %{"id" => "123"}})
assert Prefix.dispatch(msg, prefix: "!", to: Router) == :ignore
refute_received {:routed, _, _, _}
end
test "routes a matching command to the router" do
msg = %{"content" => "!ping a b", "author" => %{"id" => "7", "bot" => false}}
msg =
Dexcord.Message.from_map(%{
"content" => "!ping a b",
"author" => %{"id" => "7", "bot" => false}
})
assert Prefix.dispatch(msg, prefix: "!", to: Router) == :handled
assert_received {:routed, "ping", ["a", "b"], ^msg}
end
test "the injected catch-all returns :ignore for unknown commands" do
msg = %{"content" => "!unknown", "author" => %{"id" => "7"}}
msg = Dexcord.Message.from_map(%{"content" => "!unknown", "author" => %{"id" => "7"}})
assert Prefix.dispatch(msg, prefix: "!", to: Router) == :ignore
end
test "returns :ignore on a non-match without touching the router" do
msg = %{"content" => "no prefix", "author" => %{"id" => "7"}}
msg = Dexcord.Message.from_map(%{"content" => "no prefix", "author" => %{"id" => "7"}})
assert Prefix.dispatch(msg, prefix: "!", to: Router) == :ignore
refute_received {:routed, _, _, _}
end
test "skips messages whose author is a bot" do
msg = %{"content" => "!ping", "author" => %{"id" => "7", "bot" => true}}
msg =
Dexcord.Message.from_map(%{
"content" => "!ping",
"author" => %{"id" => "7", "bot" => true}
})
assert Prefix.dispatch(msg, prefix: "!", to: Router) == :ignore
refute_received {:routed, _, _, _}
end

View file

@ -20,8 +20,13 @@ defmodule Dexcord.FakeGateway.CacheProbeHandler do
end
end
defp probe(:GUILD_CREATE, %{"id" => id}), do: Dexcord.Cache.guild(id)
defp probe(:MESSAGE_CREATE, %{"author" => %{"id" => uid}}), do: Dexcord.Cache.user(uid)
defp probe(:GUILD_MEMBERS_CHUNK, %{"guild_id" => gid}), do: Dexcord.Cache.members(gid)
defp probe(:GUILD_CREATE, %Dexcord.Guild{id: id}), do: Dexcord.Cache.guild(id)
defp probe(:MESSAGE_CREATE, %Dexcord.Message{author: %Dexcord.User{id: uid}}),
do: Dexcord.Cache.user(uid)
defp probe(:GUILD_MEMBERS_CHUNK, %Dexcord.Events.GuildMembersChunk{guild_id: gid}),
do: Dexcord.Cache.members(gid)
defp probe(_name, _data), do: :na
end