api-surface #1
7 changed files with 324 additions and 27 deletions
feat(ergonomics): allowed_mentions config default with field-wise merge; Embed builder
commit
8070f11aa2
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue