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 324 additions and 27 deletions
Showing only changes of commit 8070f11aa2 - Show all commits

feat(ergonomics): allowed_mentions config default with field-wise merge; Embed builder

Luna 2026-07-05 08:09:56 -03:00

View file

@ -81,6 +81,8 @@ defmodule Dexcord do
slash_guild_ids = Keyword.get(opts, :slash_guild_ids)
validate_slash_guild_ids(slash_guild_ids)
allowed_mentions = validate_allowed_mentions(Keyword.get(opts, :allowed_mentions))
%{
token: token,
handler: handler,
@ -90,7 +92,8 @@ defmodule Dexcord do
request_guild_members: request_guild_members,
slash: slash,
slash_guild_ids: slash_guild_ids,
gateway_url: gateway_url
gateway_url: gateway_url,
allowed_mentions: allowed_mentions
}
end
@ -153,6 +156,21 @@ defmodule Dexcord do
"got: #{inspect(other)}"
end
# Accepts a keyword, a map, or a `%Dexcord.AllowedMentions{}`, normalized to a
# string-keyed wire map (only explicitly-provided keys). Absent -> nil.
defp validate_allowed_mentions(nil), do: nil
defp validate_allowed_mentions(spec)
when is_list(spec) or is_map(spec) do
Dexcord.AllowedMentions.normalize(spec)
end
defp validate_allowed_mentions(other) do
raise ArgumentError,
"Dexcord :allowed_mentions must be a keyword list, map, or %Dexcord.AllowedMentions{}, " <>
"got: #{inspect(other)}"
end
defp boolean_opt(opts, key, default) do
value = Keyword.get(opts, key, default)

View file

@ -392,7 +392,6 @@ defmodule Dexcord.Api do
@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
@ -406,9 +405,32 @@ defmodule Dexcord.Api do
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
# Applies the configured `allowed_mentions` default (if any) with a field-wise
# merge under any per-send value (per-send wins). Only the `send`/`reply`
# ergonomics funnel does this — the raw `create_message` endpoint stays
# mechanical. The body is normalized to a string-keyed map here so the merge
# (and `create_message`'s own encode) both see the same shape.
#
# Skipped entirely when the config key is unset AND the body carries no
# allowed_mentions, so an absent field keeps Discord's own defaults.
defp apply_allowed_mentions_default(body) do
default = Dexcord.Config.get(:allowed_mentions)
normalized = Dexcord.Api.Endpoint.encode_body(body, %{binary_wrap: :content})
case normalized do
%{} = map ->
if is_nil(default) and not Map.has_key?(map, "allowed_mentions") do
map
else
Map.update(map, "allowed_mentions", default, fn per_send ->
Dexcord.AllowedMentions.merge(default, Dexcord.AllowedMentions.normalize(per_send))
end)
end
other ->
other
end
end
# Resolve a user id to a DM channel id, opening (and caching) the DM on a miss.
defp dm_channel_id(user_id) do

View file

@ -8,4 +8,27 @@ defmodule Dexcord.AllowedMentions do
field :users, {:list, :snowflake}
field :replied_user, :boolean, default: false
end
@doc false
# Normalizes any allowed-mentions spec to a string-keyed wire map carrying only
# explicitly-provided keys (an absent key means "not set" for merge/2). A struct
# goes through to_map/1, so every non-nil field is explicit.
@spec normalize(nil | struct() | keyword() | map()) :: map() | nil
def normalize(nil), do: nil
def normalize(%{__struct__: __MODULE__} = am), do: to_map(am)
def normalize(kw) when is_list(kw), do: Map.new(kw, fn {k, v} -> {to_string(k), v} end)
def normalize(map) when is_map(map), do: Map.new(map, fn {k, v} -> {to_string(k), v} end)
@doc """
Field-wise merge of two normalized allowed-mentions maps: keys present in
`per_send` win; `default` fills the rest (discord.py's documented contract).
Callers own category-exclusivity like discord.py, this happily builds
combinations Discord would reject (e.g. `parse: ["users"]` alongside an explicit
`users:` list); it does not validate that.
"""
@spec merge(map() | nil, map() | nil) :: map() | nil
def merge(nil, per_send), do: per_send
def merge(default, nil), do: default
def merge(default, per_send), do: Map.merge(default, per_send)
end

View file

@ -1,25 +1,3 @@
defmodule Dexcord.Embed do
@moduledoc "A message embed. https://docs.discord.com/developers/resources/message"
use Dexcord.Struct
discord_struct do
field :title, :string
field :type, :string
field :description, :string
field :url, :string
field :timestamp, :datetime
field :color, :integer
field :footer, {:struct, Dexcord.EmbedFooter}
field :image, {:struct, Dexcord.EmbedImage}
field :thumbnail, {:struct, Dexcord.EmbedThumbnail}
field :video, {:struct, Dexcord.EmbedVideo}
field :provider, {:struct, Dexcord.EmbedProvider}
field :author, {:struct, Dexcord.EmbedAuthor}
field :fields, {:list, {:struct, Dexcord.EmbedField}}, default: []
field :flags, :integer
end
end
defmodule Dexcord.EmbedFooter do
@moduledoc false
use Dexcord.Struct
@ -114,3 +92,67 @@ defmodule Dexcord.EmbedField do
field :inline, :boolean, default: false
end
end
defmodule Dexcord.Embed do
@moduledoc "A message embed. https://docs.discord.com/developers/resources/message"
use Dexcord.Struct
discord_struct do
field :title, :string
field :type, :string
field :description, :string
field :url, :string
field :timestamp, :datetime
field :color, :integer
field :footer, {:struct, Dexcord.EmbedFooter}
field :image, {:struct, Dexcord.EmbedImage}
field :thumbnail, {:struct, Dexcord.EmbedThumbnail}
field :video, {:struct, Dexcord.EmbedVideo}
field :provider, {:struct, Dexcord.EmbedProvider}
field :author, {:struct, Dexcord.EmbedAuthor}
field :fields, {:list, {:struct, Dexcord.EmbedField}}, default: []
field :flags, :integer
end
@doc "A new embed. `opts` seed struct fields directly (e.g. `title:`, `color:`)."
@spec new(keyword()) :: t()
def new(opts \\ []), do: struct!(__MODULE__, opts)
@doc "Sets the embed title."
def title(embed, title), do: %{embed | title: title}
@doc "Sets the embed description."
def description(embed, description), do: %{embed | description: description}
@doc "Sets the embed url."
def url(embed, url), do: %{embed | url: url}
@doc "Sets the embed color (an integer)."
def color(embed, color) when is_integer(color), do: %{embed | color: color}
@doc "Sets the embed timestamp."
def timestamp(embed, %DateTime{} = dt), do: %{embed | timestamp: dt}
@doc "Appends a field. `inline:` defaults to `false`."
def field(embed, name, value, opts \\ []) do
f = %Dexcord.EmbedField{name: name, value: value, inline: Keyword.get(opts, :inline, false)}
%{embed | fields: embed.fields ++ [f]}
end
@doc "Sets the embed footer. `icon_url:` optional."
def footer(embed, text, opts \\ []),
do: %{embed | footer: %Dexcord.EmbedFooter{text: text, icon_url: opts[:icon_url]}}
@doc "Sets the embed image by url."
def image(embed, url), do: %{embed | image: %Dexcord.EmbedImage{url: url}}
@doc "Sets the embed thumbnail by url."
def thumbnail(embed, url), do: %{embed | thumbnail: %Dexcord.EmbedThumbnail{url: url}}
@doc "Sets the embed author. `url:`/`icon_url:` optional."
def author(embed, name, opts \\ []),
do: %{
embed
| author: %Dexcord.EmbedAuthor{name: name, url: opts[:url], icon_url: opts[:icon_url]}
}
end

View file

@ -75,4 +75,25 @@ defmodule Dexcord.ConfigValidationTest do
assert %{slash_guild_ids: nil} = validate([])
end
end
describe "allowed_mentions" do
test "a keyword is normalized to a string-keyed wire map" do
assert %{allowed_mentions: %{"parse" => []}} = validate(allowed_mentions: [parse: []])
end
test "a struct is normalized via to_map" do
assert %{allowed_mentions: %{"parse" => [], "replied_user" => false}} =
validate(allowed_mentions: %Dexcord.AllowedMentions{})
end
test "absent defaults to nil" do
assert %{allowed_mentions: nil} = validate([])
end
test "an invalid type raises a friendly ArgumentError" do
assert_raise ArgumentError, ~r/:allowed_mentions must be/, fn ->
validate(allowed_mentions: 123)
end
end
end
end

View file

@ -139,4 +139,100 @@ defmodule Dexcord.ModelMessagePartsTest do
assert am.replied_user == true
end
end
describe "AllowedMentions.normalize/1" do
test "nil stays nil" do
assert Dexcord.AllowedMentions.normalize(nil) == nil
end
test "a struct normalizes to its wire map (all set fields explicit)" do
assert Dexcord.AllowedMentions.normalize(%Dexcord.AllowedMentions{}) ==
%{"parse" => [], "replied_user" => false}
end
test "a keyword stringifies its keys, keeping only provided keys" do
assert Dexcord.AllowedMentions.normalize(parse: [], users: [5]) ==
%{"parse" => [], "users" => [5]}
end
test "a map with atom or string keys stringifies keys" do
assert Dexcord.AllowedMentions.normalize(%{parse: []}) == %{"parse" => []}
assert Dexcord.AllowedMentions.normalize(%{"users" => ["5"]}) == %{"users" => ["5"]}
end
end
describe "AllowedMentions.merge/2" do
test "a nil default yields the per-send value" do
assert Dexcord.AllowedMentions.merge(nil, %{"users" => ["5"]}) == %{"users" => ["5"]}
end
test "a nil per-send yields the default" do
assert Dexcord.AllowedMentions.merge(%{"parse" => []}, nil) == %{"parse" => []}
end
test "field-wise: per-send keys win, default fills the rest" do
assert Dexcord.AllowedMentions.merge(%{"parse" => []}, %{"users" => ["5"]}) ==
%{"parse" => [], "users" => ["5"]}
end
test "a per-send key overrides the same key in the default" do
assert Dexcord.AllowedMentions.merge(%{"parse" => ["users"]}, %{"parse" => []}) ==
%{"parse" => []}
end
end
describe "Embed builder (AC3.6)" do
test "the builder chain produces the exact wire map" do
embed =
Dexcord.Embed.new()
|> Dexcord.Embed.title("t")
|> Dexcord.Embed.color(0xFF00FF)
|> Dexcord.Embed.field("a", "b", inline: true)
|> Dexcord.Embed.footer("f")
|> Dexcord.Embed.timestamp(~U[2026-07-04 12:00:00Z])
assert Dexcord.Embed.to_map(embed) == %{
"title" => "t",
"color" => 0xFF00FF,
"fields" => [%{"name" => "a", "value" => "b", "inline" => true}],
"footer" => %{"text" => "f"},
"timestamp" => "2026-07-04T12:00:00Z"
}
end
test "description/url/image/thumbnail/author land on the struct and encode" do
embed =
Dexcord.Embed.new()
|> Dexcord.Embed.description("d")
|> Dexcord.Embed.url("https://e.com")
|> Dexcord.Embed.image("https://e.com/i.png")
|> Dexcord.Embed.thumbnail("https://e.com/t.png")
|> Dexcord.Embed.author("me", url: "https://e.com/me", icon_url: "https://e.com/me.png")
assert Dexcord.Embed.to_map(embed) == %{
"description" => "d",
"url" => "https://e.com",
"image" => %{"url" => "https://e.com/i.png"},
"thumbnail" => %{"url" => "https://e.com/t.png"},
"author" => %{
"name" => "me",
"url" => "https://e.com/me",
"icon_url" => "https://e.com/me.png"
},
"fields" => []
}
end
test "fields append in order" do
embed =
Dexcord.Embed.new()
|> Dexcord.Embed.field("a", "1")
|> Dexcord.Embed.field("b", "2", inline: true)
assert [
%Dexcord.EmbedField{name: "a", value: "1", inline: false},
%Dexcord.EmbedField{name: "b", value: "2", inline: true}
] = embed.fields
end
end
end

View file

@ -141,4 +141,79 @@ defmodule Dexcord.SendTest do
assert decoded["allowed_mentions"] == %{"users" => ["7"], "replied_user" => true}
end
end
describe "AC3.5: config-level allowed_mentions default merges field-wise per send" do
setup do
FakeRest.stub(:post, "/channels/1/messages", FakeRest.resp(200, body: ~s({"id":"1"})))
:ok
end
defp put_config_with(allowed_mentions) do
Dexcord.Config.put(%{
token: @token,
handler: nil,
intents: 0,
allowed_mentions: allowed_mentions
})
end
test "the configured default applies when the send carries none" do
put_config_with(%{"parse" => []})
assert {:ok, %Dexcord.Message{}} = Api.send(1, "hi")
assert_receive {:rest_hit, %{path: "/channels/1/messages", body: body}}
decoded = JSON.decode!(body)
assert decoded["allowed_mentions"] == %{"parse" => []}
end
test "a per-send value merges field-wise over the default (both keys survive)" do
put_config_with(%{"parse" => []})
body = %{"content" => "hi", "allowed_mentions" => %{"users" => ["5"]}}
assert {:ok, %Dexcord.Message{}} = Api.send(1, body)
assert_receive {:rest_hit, %{path: "/channels/1/messages", body: raw}}
decoded = JSON.decode!(raw)
assert decoded["allowed_mentions"] == %{"parse" => [], "users" => ["5"]}
end
test "no config default and no per-send value leaves allowed_mentions absent" do
# Base config (no :allowed_mentions) is already in place from the top setup.
assert {:ok, %Dexcord.Message{}} = Api.send(1, "hi")
assert_receive {:rest_hit, %{path: "/channels/1/messages", body: body}}
decoded = JSON.decode!(body)
refute Map.has_key?(decoded, "allowed_mentions")
end
end
describe "AC3.6: an Embed built with the builder rides Api.send as valid wire JSON" do
setup do
FakeRest.stub(:post, "/channels/1/messages", FakeRest.resp(200, body: ~s({"id":"1"})))
:ok
end
test "send/2 with embeds: [embed] serializes the embed" do
embed =
Dexcord.Embed.new()
|> Dexcord.Embed.title("t")
|> Dexcord.Embed.field("a", "b", inline: true)
assert {:ok, %Dexcord.Message{}} = Api.send(1, embeds: [embed])
assert_receive {:rest_hit, %{path: "/channels/1/messages", body: body}}
decoded = JSON.decode!(body)
assert decoded["embeds"] == [
%{
"title" => "t",
"fields" => [%{"name" => "a", "value" => "b", "inline" => true}]
}
]
end
end
end