api-surface #1
5 changed files with 316 additions and 0 deletions
feat(ergonomics): Messageable protocol + Api.send/2 with lazy DM cache
commit
8a80957faf
|
|
@ -375,6 +375,55 @@ defmodule Dexcord.Api do
|
|||
end
|
||||
end
|
||||
|
||||
# --- Ergonomic send funnel ---------------------------------------------
|
||||
|
||||
@doc """
|
||||
Sends a message to anything `Dexcord.Messageable`: channels, threads,
|
||||
messages (their channel), users/members (lazy DM), or a bare channel id.
|
||||
|
||||
The target is resolved through `Dexcord.Messageable.resolve/1` — a
|
||||
non-sendable value (a category/forum/media/directory channel) raises
|
||||
`Protocol.UndefinedError` here, before any HTTP. A user/member target opens
|
||||
(and caches) a DM channel on first use; see `Dexcord.Cache.dm_channel/1`.
|
||||
|
||||
`body` follows `Dexcord.Api.Messages.create_message/2`: a binary (wrapped as
|
||||
`content`), or a keyword/map/struct body.
|
||||
"""
|
||||
@spec send(Dexcord.Messageable.t(), term(), keyword()) ::
|
||||
{:ok, Dexcord.Message.t()} | {:error, Error.t()}
|
||||
def send(target, body, opts \\ []) do
|
||||
# Task 3 replaces this stub with the allowed-mentions config-default merge.
|
||||
body = apply_allowed_mentions_default(body)
|
||||
|
||||
case Dexcord.Messageable.resolve(target) do
|
||||
{:channel, channel_id} ->
|
||||
Dexcord.Api.Messages.create_message(channel_id, body, opts)
|
||||
|
||||
{:dm_user, user_id} ->
|
||||
with {:ok, channel_id} <- dm_channel_id(user_id) do
|
||||
Dexcord.Api.Messages.create_message(channel_id, body, opts)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Identity for now; Task 3 makes this apply the configured allowed_mentions
|
||||
# default with a field-wise merge over any per-send value.
|
||||
defp apply_allowed_mentions_default(body), do: body
|
||||
|
||||
# Resolve a user id to a DM channel id, opening (and caching) the DM on a miss.
|
||||
defp dm_channel_id(user_id) do
|
||||
case Dexcord.Cache.dm_channel(user_id) do
|
||||
{:ok, channel_id} ->
|
||||
{:ok, channel_id}
|
||||
|
||||
:error ->
|
||||
with {:ok, %Dexcord.DMChannel{id: id}} <- Dexcord.Api.Users.create_dm(user_id) do
|
||||
Dexcord.Cache.put_dm_channel(user_id, id)
|
||||
{:ok, id}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# --- Generated endpoint facade -----------------------------------------
|
||||
#
|
||||
# The typed endpoint surface lives in the group modules below (declared with
|
||||
|
|
|
|||
|
|
@ -21,6 +21,17 @@ defmodule Dexcord.Cache do
|
|||
is always the source of truth; the cache is best-effort, and can briefly lag or
|
||||
hold a duplicate after a resume gap.
|
||||
|
||||
### The one sanctioned writer exception: DM channels
|
||||
|
||||
`:dexcord_dm_channels` is the single table written from OUTSIDE the Dispatcher —
|
||||
the `Dexcord.Api.send/2` funnel calls `put_dm_channel/2` after lazily opening a
|
||||
DM (`Dexcord.Cache.dm_channel/1` misses → `POST /users/@me/channels` → cache the
|
||||
id). This breaks the single-writer rule on purpose, and it is safe: DM channel
|
||||
ids are stable and idempotent (Discord returns the SAME channel for a given
|
||||
recipient), so two processes racing to open a DM for the same user both compute
|
||||
and store the same id — a harmless duplicate write, never a conflicting one. The
|
||||
table is `:set`/`:public` like the rest, so reads stay lock-free.
|
||||
|
||||
## Tables
|
||||
|
||||
| Table | Type | Key |
|
||||
|
|
@ -59,6 +70,10 @@ defmodule Dexcord.Cache do
|
|||
@roles :dexcord_roles
|
||||
@presences :dexcord_presences
|
||||
@voice_states :dexcord_voice_states
|
||||
# Written by the `Dexcord.Api.send/2` funnel, not the Dispatcher — the one
|
||||
# sanctioned exception to single-writer (see moduledoc). Key: user_id -> DM
|
||||
# channel id.
|
||||
@dm_channels :dexcord_dm_channels
|
||||
|
||||
# Guild child collections lifted out of the guild row into their own tables.
|
||||
# `emojis` (and stickers) stay inline on the guild and are replaced wholesale.
|
||||
|
|
@ -85,6 +100,7 @@ defmodule Dexcord.Cache do
|
|||
:ets.new(@roles, oset)
|
||||
:ets.new(@presences, oset)
|
||||
:ets.new(@voice_states, oset)
|
||||
:ets.new(@dm_channels, set)
|
||||
|
||||
{:ok, %{}}
|
||||
end
|
||||
|
|
@ -566,6 +582,32 @@ defmodule Dexcord.Cache do
|
|||
@spec voice_states(id()) :: [entity()]
|
||||
def voice_states(guild_id), do: prefix_values(@voice_states, guild_id)
|
||||
|
||||
# --- DM channel cache (written by the send funnel, see moduledoc) --------
|
||||
|
||||
@doc """
|
||||
The cached DM channel id for a user, if one has been opened this session.
|
||||
|
||||
Populated lazily by `Dexcord.Api.send/2` the first time it DMs a user; a miss
|
||||
is `:error` (the funnel then opens the DM and caches the result).
|
||||
"""
|
||||
@spec dm_channel(id()) :: {:ok, Dexcord.Snowflake.t()} | :error
|
||||
def dm_channel(user_id) do
|
||||
with {:ok, uid} <- Dexcord.Snowflake.cast(user_id), do: fetch(@dm_channels, uid)
|
||||
end
|
||||
|
||||
@doc false
|
||||
# Written by the `Dexcord.Api.send/2` funnel — the sole sanctioned non-Dispatcher
|
||||
# writer (see moduledoc). Idempotent: DM channel ids are stable per recipient, so
|
||||
# racing writers store the same value.
|
||||
@spec put_dm_channel(id(), Dexcord.Snowflake.t()) :: :ok
|
||||
def put_dm_channel(user_id, channel_id) do
|
||||
with {:ok, uid} <- Dexcord.Snowflake.cast(user_id) do
|
||||
:ets.insert(@dm_channels, {uid, channel_id})
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
# --- hydration ----------------------------------------------------------
|
||||
|
||||
@doc """
|
||||
|
|
|
|||
45
lib/dexcord/messageable.ex
Normal file
45
lib/dexcord/messageable.ex
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
defprotocol Dexcord.Messageable do
|
||||
@moduledoc """
|
||||
Anything a message can be sent to. `Dexcord.Api.send/2` resolves its target
|
||||
through this protocol. Category/forum/media/directory channels deliberately
|
||||
do NOT implement it — sending to one fails with `Protocol.UndefinedError`
|
||||
at resolve time, before any HTTP.
|
||||
"""
|
||||
|
||||
@spec resolve(t) :: {:channel, Dexcord.Snowflake.t()} | {:dm_user, Dexcord.Snowflake.t()}
|
||||
def resolve(target)
|
||||
end
|
||||
|
||||
defimpl Dexcord.Messageable,
|
||||
for: [
|
||||
Dexcord.TextChannel,
|
||||
Dexcord.AnnouncementChannel,
|
||||
Dexcord.VoiceChannel,
|
||||
Dexcord.StageChannel,
|
||||
Dexcord.Thread,
|
||||
Dexcord.DMChannel,
|
||||
Dexcord.GroupDMChannel
|
||||
] do
|
||||
def resolve(%{id: id}), do: {:channel, id}
|
||||
end
|
||||
|
||||
defimpl Dexcord.Messageable, for: Dexcord.Message do
|
||||
def resolve(%{channel_id: id}), do: {:channel, id}
|
||||
end
|
||||
|
||||
defimpl Dexcord.Messageable, for: Dexcord.Interaction do
|
||||
def resolve(%{channel_id: id}) when not is_nil(id), do: {:channel, id}
|
||||
end
|
||||
|
||||
defimpl Dexcord.Messageable, for: Dexcord.User do
|
||||
def resolve(%{id: id}), do: {:dm_user, id}
|
||||
end
|
||||
|
||||
defimpl Dexcord.Messageable, for: Dexcord.Member do
|
||||
def resolve(%{user: %Dexcord.User{id: id}}), do: {:dm_user, id}
|
||||
def resolve(%{user_id: id}) when not is_nil(id), do: {:dm_user, id}
|
||||
end
|
||||
|
||||
defimpl Dexcord.Messageable, for: Integer do
|
||||
def resolve(id) when id >= 0, do: {:channel, id}
|
||||
end
|
||||
79
test/dexcord/messageable_test.exs
Normal file
79
test/dexcord/messageable_test.exs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
defmodule Dexcord.MessageableTest do
|
||||
@moduledoc """
|
||||
Pure resolve tests for the `Dexcord.Messageable` protocol (api-surface.AC3.1's
|
||||
match-time layer). No HTTP: every case exercises `Dexcord.Messageable.resolve/1`
|
||||
directly, including the deliberate non-implementations that must fail BEFORE any
|
||||
network call.
|
||||
"""
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Dexcord.Messageable
|
||||
|
||||
describe "channel-like targets resolve to {:channel, id}" do
|
||||
test "guild text/announcement/voice/stage channels" do
|
||||
assert Messageable.resolve(%Dexcord.TextChannel{id: 1}) == {:channel, 1}
|
||||
assert Messageable.resolve(%Dexcord.AnnouncementChannel{id: 2}) == {:channel, 2}
|
||||
assert Messageable.resolve(%Dexcord.VoiceChannel{id: 3}) == {:channel, 3}
|
||||
assert Messageable.resolve(%Dexcord.StageChannel{id: 4}) == {:channel, 4}
|
||||
end
|
||||
|
||||
test "threads and DM/group-DM channels" do
|
||||
assert Messageable.resolve(%Dexcord.Thread{id: 9}) == {:channel, 9}
|
||||
assert Messageable.resolve(%Dexcord.DMChannel{id: 10}) == {:channel, 10}
|
||||
assert Messageable.resolve(%Dexcord.GroupDMChannel{id: 11}) == {:channel, 11}
|
||||
end
|
||||
|
||||
test "a message resolves to its channel" do
|
||||
assert Messageable.resolve(%Dexcord.Message{id: 100, channel_id: 5}) == {:channel, 5}
|
||||
end
|
||||
|
||||
test "an interaction resolves to its channel" do
|
||||
assert Messageable.resolve(%Dexcord.Interaction{id: 1, channel_id: 7}) == {:channel, 7}
|
||||
end
|
||||
|
||||
test "a bare non-negative integer passes through" do
|
||||
assert Messageable.resolve(123) == {:channel, 123}
|
||||
assert Messageable.resolve(0) == {:channel, 0}
|
||||
end
|
||||
end
|
||||
|
||||
describe "user-like targets resolve to {:dm_user, id}" do
|
||||
test "a user" do
|
||||
assert Messageable.resolve(%Dexcord.User{id: 42}) == {:dm_user, 42}
|
||||
end
|
||||
|
||||
test "a member via its nested user" do
|
||||
assert Messageable.resolve(%Dexcord.Member{user: %Dexcord.User{id: 43}}) ==
|
||||
{:dm_user, 43}
|
||||
end
|
||||
|
||||
test "a member via its user_id back-reference (cache shape, no nested user)" do
|
||||
assert Messageable.resolve(%Dexcord.Member{user: nil, user_id: 44}) == {:dm_user, 44}
|
||||
end
|
||||
end
|
||||
|
||||
describe "deliberate non-implementations fail at resolve time" do
|
||||
# These structs are built via `struct/2` rather than a literal so the
|
||||
# set-theoretic type checker can't statically flag the (intentional)
|
||||
# protocol violation we're asserting on at runtime.
|
||||
test "category channels are not Messageable" do
|
||||
target = struct(Dexcord.CategoryChannel, id: 1)
|
||||
assert_raise Protocol.UndefinedError, fn -> Messageable.resolve(target) end
|
||||
end
|
||||
|
||||
test "forum channels are not Messageable" do
|
||||
target = struct(Dexcord.ForumChannel, id: 1)
|
||||
assert_raise Protocol.UndefinedError, fn -> Messageable.resolve(target) end
|
||||
end
|
||||
|
||||
test "media channels are not Messageable" do
|
||||
target = struct(Dexcord.MediaChannel, id: 1)
|
||||
assert_raise Protocol.UndefinedError, fn -> Messageable.resolve(target) end
|
||||
end
|
||||
|
||||
test "directory channels are not Messageable" do
|
||||
target = struct(Dexcord.DirectoryChannel, id: 1)
|
||||
assert_raise Protocol.UndefinedError, fn -> Messageable.resolve(target) end
|
||||
end
|
||||
end
|
||||
end
|
||||
101
test/dexcord/send_test.exs
Normal file
101
test/dexcord/send_test.exs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
defmodule Dexcord.SendTest do
|
||||
@moduledoc """
|
||||
Wire tests for the `Dexcord.Api.send/2,3` funnel and its lazy DM dance
|
||||
(api-surface.AC3.1, api-surface.AC3.2), driven through `Dexcord.FakeRest`.
|
||||
"""
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Dexcord.Api
|
||||
alias Dexcord.Api.Ratelimit
|
||||
alias Dexcord.FakeRest
|
||||
|
||||
@token "test.token.value"
|
||||
|
||||
setup do
|
||||
Dexcord.EnvSandbox.sandbox_env()
|
||||
Dexcord.Config.put(%{token: @token, handler: nil, intents: 0})
|
||||
|
||||
start_supervised!({Finch, name: Dexcord.Finch})
|
||||
start_supervised!(Ratelimit)
|
||||
start_supervised!(Dexcord.Cache)
|
||||
start_supervised!(FakeRest)
|
||||
|
||||
Application.put_env(:dexcord, :api_base_url, FakeRest.base_url())
|
||||
FakeRest.subscribe(self())
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
describe "AC3.1: send/2 resolves channels, threads, messages, and bare ids to the right route" do
|
||||
test "a text channel struct" do
|
||||
FakeRest.stub(:post, "/channels/1/messages", FakeRest.resp(200, body: ~s({"id":"1"})))
|
||||
|
||||
assert {:ok, %Dexcord.Message{}} = Api.send(%Dexcord.TextChannel{id: 1}, "hi")
|
||||
|
||||
assert_receive {:rest_hit, %{method: "POST", path: "/channels/1/messages"}}
|
||||
end
|
||||
|
||||
test "a thread struct" do
|
||||
FakeRest.stub(:post, "/channels/9/messages", FakeRest.resp(200, body: ~s({"id":"2"})))
|
||||
|
||||
assert {:ok, %Dexcord.Message{}} = Api.send(%Dexcord.Thread{id: 9}, "hi")
|
||||
|
||||
assert_receive {:rest_hit, %{path: "/channels/9/messages"}}
|
||||
end
|
||||
|
||||
test "a bare snowflake" do
|
||||
FakeRest.stub(:post, "/channels/123/messages", FakeRest.resp(200, body: ~s({"id":"3"})))
|
||||
|
||||
assert {:ok, %Dexcord.Message{}} = Api.send(123, "hi")
|
||||
|
||||
assert_receive {:rest_hit, %{path: "/channels/123/messages"}}
|
||||
end
|
||||
|
||||
test "a message resolves to its channel" do
|
||||
FakeRest.stub(:post, "/channels/5/messages", FakeRest.resp(200, body: ~s({"id":"4"})))
|
||||
|
||||
assert {:ok, %Dexcord.Message{}} = Api.send(%Dexcord.Message{channel_id: 5}, "hi")
|
||||
|
||||
assert_receive {:rest_hit, %{path: "/channels/5/messages"}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "AC3.2: send/2 to a user lazily creates the DM once, then reuses it" do
|
||||
setup do
|
||||
FakeRest.stub(
|
||||
:post,
|
||||
"/users/@me/channels",
|
||||
FakeRest.resp(200, body: ~s({"id":"77","type":1}))
|
||||
)
|
||||
|
||||
FakeRest.stub(:post, "/channels/77/messages", FakeRest.resp(200, body: ~s({"id":"9"})))
|
||||
:ok
|
||||
end
|
||||
|
||||
test "first send hits create-DM then the message; second send skips create-DM" do
|
||||
# First send: create the DM channel, then post the message.
|
||||
assert {:ok, %Dexcord.Message{}} = Api.send(%Dexcord.User{id: 42}, "yo")
|
||||
|
||||
assert_receive {:rest_hit, %{method: "POST", path: "/users/@me/channels"}}
|
||||
assert_receive {:rest_hit, %{method: "POST", path: "/channels/77/messages"}}
|
||||
|
||||
# Second send: the DM channel id is cached, so ONLY a message hit occurs.
|
||||
assert {:ok, %Dexcord.Message{}} = Api.send(%Dexcord.User{id: 42}, "again")
|
||||
|
||||
assert_receive {:rest_hit, %{method: "POST", path: "/channels/77/messages"}}
|
||||
refute_receive {:rest_hit, %{path: "/users/@me/channels"}}, 50
|
||||
end
|
||||
|
||||
test "a member (via nested user) reuses the same cached DM as the user" do
|
||||
assert {:ok, %Dexcord.Message{}} = Api.send(%Dexcord.User{id: 42}, "first")
|
||||
assert_receive {:rest_hit, %{path: "/users/@me/channels"}}
|
||||
assert_receive {:rest_hit, %{path: "/channels/77/messages"}}
|
||||
|
||||
assert {:ok, %Dexcord.Message{}} =
|
||||
Api.send(%Dexcord.Member{user: %Dexcord.User{id: 42}}, "second")
|
||||
|
||||
assert_receive {:rest_hit, %{path: "/channels/77/messages"}}
|
||||
refute_receive {:rest_hit, %{path: "/users/@me/channels"}}, 50
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue