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 316 additions and 0 deletions
Showing only changes of commit 8a80957faf - Show all commits

feat(ergonomics): Messageable protocol + Api.send/2 with lazy DM cache

Luna 2026-07-05 07:50:59 -03:00

View file

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

View file

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

View 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

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