api-surface #1
7 changed files with 161 additions and 52 deletions
feat(dispatcher): typed handler delivery with raw fallback; Prefix on %Message{}
commit
cdca1f13ce
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue