api-surface #1

Merged
luna merged 51 commits from api-surface into mistress 2026-07-05 14:43:13 +00:00
11 changed files with 378 additions and 0 deletions
Showing only changes of commit 99208f2212 - Show all commits

feat(ergonomics): reply/2, mentions + String.Chars, created_at, format_dt

Luna 2026-07-05 08:03:35 -03:00

View file

@ -30,6 +30,10 @@ defmodule Dexcord.TextChannel do
field :default_auto_archive_duration, :integer
field :default_thread_rate_limit_per_user, :integer
end
@doc "The creation `DateTime` encoded in this channel's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
end
defmodule Dexcord.AnnouncementChannel do
@ -45,6 +49,10 @@ defmodule Dexcord.AnnouncementChannel do
field :default_auto_archive_duration, :integer
field :default_thread_rate_limit_per_user, :integer
end
@doc "The creation `DateTime` encoded in this channel's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
end
defmodule Dexcord.VoiceChannel do
@ -60,6 +68,10 @@ defmodule Dexcord.VoiceChannel do
field :last_message_id, :snowflake
field :rate_limit_per_user, :integer
end
@doc "The creation `DateTime` encoded in this channel's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
end
defmodule Dexcord.StageChannel do
@ -75,6 +87,10 @@ defmodule Dexcord.StageChannel do
field :last_message_id, :snowflake
field :rate_limit_per_user, :integer
end
@doc "The creation `DateTime` encoded in this channel's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
end
defmodule Dexcord.CategoryChannel do
@ -84,6 +100,10 @@ defmodule Dexcord.CategoryChannel do
discord_struct do
include_fields Dexcord.Model.ChannelShared
end
@doc "The creation `DateTime` encoded in this channel's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
end
defmodule Dexcord.DirectoryChannel do
@ -93,6 +113,10 @@ defmodule Dexcord.DirectoryChannel do
discord_struct do
include_fields Dexcord.Model.ChannelShared
end
@doc "The creation `DateTime` encoded in this channel's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
end
defmodule Dexcord.ForumChannel do
@ -111,6 +135,10 @@ defmodule Dexcord.ForumChannel do
field :default_sort_order, {:enum, Dexcord.SortOrderType}
field :default_forum_layout, {:enum, Dexcord.ForumLayoutType}
end
@doc "The creation `DateTime` encoded in this channel's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
end
defmodule Dexcord.MediaChannel do
@ -128,6 +156,10 @@ defmodule Dexcord.MediaChannel do
field :default_reaction_emoji, {:struct, Dexcord.DefaultReaction}
field :default_sort_order, {:enum, Dexcord.SortOrderType}
end
@doc "The creation `DateTime` encoded in this channel's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
end
defmodule Dexcord.DMChannel do
@ -142,6 +174,10 @@ defmodule Dexcord.DMChannel do
field :recipients, {:list, {:struct, Dexcord.User}}, default: []
field :last_pin_timestamp, :datetime
end
@doc "The creation `DateTime` encoded in this channel's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
end
defmodule Dexcord.GroupDMChannel do
@ -161,6 +197,10 @@ defmodule Dexcord.GroupDMChannel do
field :application_id, :snowflake
field :managed, :boolean
end
@doc "The creation `DateTime` encoded in this channel's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
end
defmodule Dexcord.Thread do
@ -186,6 +226,10 @@ defmodule Dexcord.Thread do
field :last_pin_timestamp, :datetime
field :newly_created, :boolean, default: false
end
@doc "The creation `DateTime` encoded in this thread's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
end
defmodule Dexcord.UnknownChannel do
@ -254,6 +298,32 @@ defmodule Dexcord.Channel do
def from_map(map) when is_map(map), do: Dexcord.UnknownChannel.from_map(map)
def from_map(_), do: nil
@doc """
The mention string (`<#id>`) for any channel struct with an integer `id` —
guild channels, threads, and DM/group-DM channels alike.
"""
@spec mention(%{id: Dexcord.Snowflake.t()}) :: String.t()
def mention(%{id: id}) when is_integer(id), do: "<##{id}>"
@doc false
def __type_map__, do: @type_map
end
# `String.Chars` for every channel struct: interpolating a channel produces a
# real `<#id>` mention (delegating to `Dexcord.Channel.mention/1`).
defimpl String.Chars,
for: [
Dexcord.TextChannel,
Dexcord.AnnouncementChannel,
Dexcord.VoiceChannel,
Dexcord.StageChannel,
Dexcord.CategoryChannel,
Dexcord.DirectoryChannel,
Dexcord.ForumChannel,
Dexcord.MediaChannel,
Dexcord.DMChannel,
Dexcord.GroupDMChannel,
Dexcord.Thread
] do
def to_string(channel), do: Dexcord.Channel.mention(channel)
end

View file

@ -12,6 +12,10 @@ defmodule Dexcord.Emoji do
field :animated, :boolean, default: false
field :available, :boolean
end
@doc "The creation `DateTime` encoded in this emoji's id, or `:error` (unicode/nil id)."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
end
defmodule Dexcord.PartialEmoji do
@ -22,9 +26,27 @@ defmodule Dexcord.PartialEmoji do
"""
use Dexcord.Struct
# `to_string/1` below shadows the auto-imported `Kernel.to_string/1`.
import Kernel, except: [to_string: 1]
discord_struct do
field :id, :snowflake
field :name, :string
field :animated, :boolean, default: false
end
@doc """
The send-format string for this emoji.
Custom static: `<:name:id>`; animated: `<a:name:id>`; unicode (nil id): the
raw `name` character.
"""
@spec to_string(t()) :: String.t()
def to_string(%{id: nil, name: name}), do: name
def to_string(%{id: id, name: name, animated: true}), do: "<a:#{name}:#{id}>"
def to_string(%{id: id, name: name}), do: "<:#{name}:#{id}>"
end
defimpl String.Chars, for: Dexcord.PartialEmoji do
def to_string(emoji), do: Dexcord.PartialEmoji.to_string(emoji)
end

View file

@ -57,6 +57,10 @@ defmodule Dexcord.Guild do
field :guild_scheduled_events, {:list, {:struct, Dexcord.GuildScheduledEvent}}, default: []
field :soundboard_sounds, {:list, :raw}, default: []
end
@doc "The creation `DateTime` encoded in this guild's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
end
defmodule Dexcord.UnavailableGuild do

View file

@ -34,6 +34,23 @@ defmodule Dexcord.Member do
field :user, {:struct, Dexcord.User}
include_fields Dexcord.Model.MemberShared
end
@doc "The mention string for this member (`<@id>`), from `user_id` or the nested user."
@spec mention(t()) :: String.t()
def mention(%{} = member), do: "<@#{member_user_id(member)}>"
@doc "The creation `DateTime` encoded in this member's user id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{} = member),
do: Dexcord.Snowflake.to_datetime(member_user_id(member))
defp member_user_id(%{user_id: user_id, user: user}) do
user_id || (user && user.id)
end
end
defimpl String.Chars, for: Dexcord.Member do
def to_string(member), do: Dexcord.Member.mention(member)
end
defmodule Dexcord.PartialMember do

View file

@ -44,6 +44,47 @@ defmodule Dexcord.Message do
field :call, {:struct, Dexcord.MessageCall}
field :shared_client_theme, :raw
end
@doc "The creation `DateTime` encoded in this message's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
@doc """
Replies to this message. Sets `message_reference` to the source message and
routes through `Dexcord.Api.send/2` (so the allowed-mentions config default
merge applies).
`mention_author: true | false` overrides `allowed_mentions.replied_user`
(mirroring discord.py) it is applied AFTER the body is normalized, so it
wins over any `replied_user` a caller passed in the body. Everything else
behaves like `Dexcord.Api.send/3`.
"""
@spec reply(t(), term(), keyword()) ::
{:ok, t()} | {:error, Dexcord.Api.Error.t()}
def reply(msg, body, opts \\ [])
def reply(%{id: id, channel_id: channel_id}, body, opts) do
{mention_author, opts} = Keyword.pop(opts, :mention_author)
body =
body
|> Dexcord.Api.Endpoint.encode_body(%{binary_wrap: :content})
|> Map.put("message_reference", %{"message_id" => Dexcord.Snowflake.dump(id)})
|> apply_mention_author(mention_author)
Dexcord.Api.send(channel_id, body, opts)
end
defp apply_mention_author(body, nil), do: body
defp apply_mention_author(body, flag) when is_boolean(flag) do
Map.update(
body,
"allowed_mentions",
%{"replied_user" => flag},
&Map.put(&1, "replied_user", flag)
)
end
end
defmodule Dexcord.MessageReference do

View file

@ -17,6 +17,18 @@ defmodule Dexcord.Role do
field :tags, {:struct, Dexcord.RoleTags}
field :flags, {:flags, Dexcord.RoleFlags}
end
@doc "The mention string for this role (`<@&id>`)."
@spec mention(t()) :: String.t()
def mention(%{id: id}), do: "<@&#{id}>"
@doc "The creation `DateTime` encoded in this role's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
end
defimpl String.Chars, for: Dexcord.Role do
def to_string(role), do: Dexcord.Role.mention(role)
end
defmodule Dexcord.RoleColors do

View file

@ -23,6 +23,18 @@ defmodule Dexcord.User do
field :collectibles, :raw
field :primary_guild, {:struct, Dexcord.PrimaryGuild}
end
@doc "The mention string for this user (`<@id>`)."
@spec mention(t()) :: String.t()
def mention(%{id: id}), do: "<@#{id}>"
@doc "The creation `DateTime` encoded in this user's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
end
defimpl String.Chars, for: Dexcord.User do
def to_string(user), do: Dexcord.User.mention(user)
end
defmodule Dexcord.AvatarDecorationData do

View file

@ -16,4 +16,8 @@ defmodule Dexcord.Webhook do
field :source_channel, :raw
field :url, :string
end
@doc "The creation `DateTime` encoded in this webhook's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
end

22
lib/dexcord/util.ex Normal file
View file

@ -0,0 +1,22 @@
defmodule Dexcord.Util do
@moduledoc "Small formatting helpers for the ergonomics layer."
# The nine documented Discord timestamp styles (Message Formatting reference):
# t short time, T long time, d short date, D long date, f short date/time,
# F long date/time, s short relative-ish (seconds), S, R relative.
@styles ~w(t T d D f F s S R)
@doc """
Renders a Discord timestamp markdown token `<t:unix[:style]>`.
Accepts a `DateTime` or a unix-seconds integer. `style` is one of
`#{Enum.join(@styles, " ")}` and defaults to `"f"` (per Discord's docs).
"""
@spec format_dt(DateTime.t() | integer(), String.t()) :: String.t()
def format_dt(dt_or_unix, style \\ "f")
def format_dt(%DateTime{} = dt, style), do: format_dt(DateTime.to_unix(dt), style)
def format_dt(unix, style) when is_integer(unix) and style in @styles,
do: "<t:#{unix}:#{style}>"
end

View file

@ -0,0 +1,131 @@
defmodule Dexcord.ErgonomicsHelpersTest do
@moduledoc """
Pure helper tests for the discord.py taste layer (api-surface.AC3.4): mentions,
`String.Chars` interpolation, `Dexcord.PartialEmoji.to_string/1`, `created_at/1`,
and `Dexcord.Util.format_dt/2`.
"""
use ExUnit.Case, async: true
alias Dexcord.Util
# Discord's documented example snowflake -> 2016-04-30T11:18:25.796Z.
@known_snowflake 175_928_847_299_117_063
@known_unix_ms 1_462_015_105_796
describe "mention/1" do
test "User renders <@id>" do
assert Dexcord.User.mention(%Dexcord.User{id: 1}) == "<@1>"
end
test "Member renders <@id> from user_id" do
assert Dexcord.Member.mention(%Dexcord.Member{user_id: 7}) == "<@7>"
end
test "Member renders <@id> from the nested user when user_id is nil" do
assert Dexcord.Member.mention(%Dexcord.Member{user: %Dexcord.User{id: 8}}) == "<@8>"
end
test "Role renders <@&id>" do
assert Dexcord.Role.mention(%Dexcord.Role{id: 2}) == "<@&2>"
end
test "Dexcord.Channel.mention/1 renders <#id> for any channel struct" do
assert Dexcord.Channel.mention(%Dexcord.TextChannel{id: 3}) == "<#3>"
assert Dexcord.Channel.mention(%Dexcord.VoiceChannel{id: 4}) == "<#4>"
assert Dexcord.Channel.mention(%Dexcord.Thread{id: 5}) == "<#5>"
assert Dexcord.Channel.mention(%Dexcord.DMChannel{id: 6}) == "<#6>"
end
end
describe "String.Chars interpolation" do
test "a user interpolates as a real ping" do
assert "hey #{%Dexcord.User{id: 1}}" == "hey <@1>"
end
test "a member interpolates as a real ping" do
assert "#{%Dexcord.Member{user_id: 9}}" == "<@9>"
end
test "a role interpolates as <@&id>" do
assert "#{%Dexcord.Role{id: 2}}" == "<@&2>"
end
test "a text channel interpolates as <#id>" do
assert "#{%Dexcord.TextChannel{id: 3}}" == "<#3>"
end
test "a thread interpolates as <#id>" do
assert "#{%Dexcord.Thread{id: 5}}" == "<#5>"
end
end
describe "PartialEmoji.to_string/1" do
test "a custom (static) emoji renders <:name:id>" do
emoji = %Dexcord.PartialEmoji{id: 100, name: "blob", animated: false}
assert Dexcord.PartialEmoji.to_string(emoji) == "<:blob:100>"
end
test "an animated emoji renders <a:name:id>" do
emoji = %Dexcord.PartialEmoji{id: 200, name: "party", animated: true}
assert Dexcord.PartialEmoji.to_string(emoji) == "<a:party:200>"
end
test "a unicode emoji (nil id) renders its raw name" do
emoji = %Dexcord.PartialEmoji{id: nil, name: "🔥"}
assert Dexcord.PartialEmoji.to_string(emoji) == "🔥"
end
test "interpolates via String.Chars" do
assert "#{%Dexcord.PartialEmoji{id: 100, name: "blob"}}" == "<:blob:100>"
assert "#{%Dexcord.PartialEmoji{id: nil, name: "🔥"}}" == "🔥"
end
end
describe "Dexcord.Util.format_dt/2" do
test "renders every documented style" do
for style <- ~w(t T d D f F s S R) do
assert Util.format_dt(1_700_000_000, style) == "<t:1700000000:#{style}>"
end
end
test "defaults to the f style" do
assert Util.format_dt(1_700_000_000) == "<t:1700000000:f>"
end
test "accepts a DateTime and converts to unix" do
dt = DateTime.from_unix!(1_700_000_000)
assert Util.format_dt(dt) == "<t:1700000000:f>"
assert Util.format_dt(dt, "R") == "<t:1700000000:R>"
end
end
describe "created_at/1" do
test "round-trips a known snowflake vector on a User" do
assert {:ok, dt} = Dexcord.User.created_at(%Dexcord.User{id: @known_snowflake})
assert DateTime.to_unix(dt, :millisecond) == @known_unix_ms
end
test "works on a Message via its id" do
assert {:ok, dt} = Dexcord.Message.created_at(%Dexcord.Message{id: @known_snowflake})
assert DateTime.to_unix(dt, :millisecond) == @known_unix_ms
end
test "works on a Member via user_id" do
assert {:ok, dt} =
Dexcord.Member.created_at(%Dexcord.Member{user_id: @known_snowflake})
assert DateTime.to_unix(dt, :millisecond) == @known_unix_ms
end
test "works on a guild channel struct" do
assert {:ok, dt} =
Dexcord.TextChannel.created_at(%Dexcord.TextChannel{id: @known_snowflake})
assert DateTime.to_unix(dt, :millisecond) == @known_unix_ms
end
test "an emoji with a nil id returns :error" do
assert Dexcord.Emoji.created_at(%Dexcord.Emoji{id: nil}) == :error
end
end
end

View file

@ -98,4 +98,47 @@ defmodule Dexcord.SendTest do
refute_receive {:rest_hit, %{path: "/users/@me/channels"}}, 50
end
end
describe "AC3.3: Message.reply/2,3 sets message_reference and mention_author" do
setup do
FakeRest.stub(:post, "/channels/5/messages", FakeRest.resp(200, body: ~s({"id":"1"})))
%{msg: %Dexcord.Message{id: 555, channel_id: 5}}
end
test "reply/2 sets message_reference to the source message", %{msg: msg} do
assert {:ok, %Dexcord.Message{}} = Dexcord.Message.reply(msg, "pong")
assert_receive {:rest_hit, %{path: "/channels/5/messages", body: body}}
decoded = JSON.decode!(body)
assert decoded["content"] == "pong"
assert decoded["message_reference"] == %{"message_id" => "555"}
refute Map.has_key?(decoded, "allowed_mentions")
end
test "mention_author: false sets allowed_mentions.replied_user to false", %{msg: msg} do
assert {:ok, %Dexcord.Message{}} = Dexcord.Message.reply(msg, "pong", mention_author: false)
assert_receive {:rest_hit, %{path: "/channels/5/messages", body: body}}
decoded = JSON.decode!(body)
assert decoded["allowed_mentions"] == %{"replied_user" => false}
assert decoded["message_reference"] == %{"message_id" => "555"}
end
test "mention_author: true preserves other allowed_mentions keys and wins over the body",
%{msg: msg} do
body = %{
"content" => "pong",
"allowed_mentions" => %{"users" => ["7"], "replied_user" => false}
}
assert {:ok, %Dexcord.Message{}} = Dexcord.Message.reply(msg, body, mention_author: true)
assert_receive {:rest_hit, %{path: "/channels/5/messages", body: raw}}
decoded = JSON.decode!(raw)
assert decoded["allowed_mentions"] == %{"users" => ["7"], "replied_user" => true}
end
end
end